feat: Implement initial setup service for admin user creation

- Added `SetupService` to handle the generation and validation of setup tokens.
- Integrated setup token generation during application startup if no admin users exist.
- Created API endpoints for checking setup status and completing the initial setup.
- Updated `AuthService` to include functionality for creating the initial admin user.
- Enhanced error handling for setup and authentication processes.
- Added frontend components for login and protected routes.
- Implemented Zustand store for managing authentication state.
- Updated Vite configuration to check setup status and serve the setup page if required.
- Documented the initial setup process in `setup.md`.
This commit is contained in:
GW_MC
2026-03-03 07:46:49 +00:00
parent 520ab74391
commit 4eddf7e094
24 changed files with 2214 additions and 99 deletions

1
Cargo.lock generated
View File

@@ -2475,6 +2475,7 @@ dependencies = [
"mockall", "mockall",
"nxmesh-core", "nxmesh-core",
"nxmesh-proto", "nxmesh-proto",
"rand 0.8.5",
"sea-orm", "sea-orm",
"sea-orm-migration", "sea-orm-migration",
"serde", "serde",

View File

@@ -74,6 +74,9 @@ uuid.workspace = true
# Templating # Templating
handlebars.workspace = true handlebars.workspace = true
# Random generation
rand = "0.8"
[dev-dependencies] [dev-dependencies]
tokio-test.workspace = true tokio-test.workspace = true
mockall.workspace = true mockall.workspace = true

View File

@@ -1,6 +1,7 @@
//! API middleware //! API middleware
pub mod auth; pub mod auth;
pub mod setup_check;
use axum::{ use axum::{
extract::Request, extract::Request,

View File

@@ -0,0 +1,55 @@
//! Setup check middleware
//!
//! This middleware checks if initial setup is required (no admin exists).
//! If setup is required and the request is for the main app (/, /index.html),
//! it serves the setup page instead.
use axum::{
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::{Html, IntoResponse, Response},
};
use std::sync::Arc;
use crate::api::routes::AppState;
use crate::api::setup_page::SETUP_PAGE_HTML;
/// Paths that should be checked for setup requirement
const SETUP_CHECK_PATHS: &[&str] = &["/", "/index.html"];
/// Middleware that serves setup page when no admin exists
pub async fn setup_check_middleware(
State(state): State<AppState>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
let path = request.uri().path();
// Only intercept main app paths
if !SETUP_CHECK_PATHS.contains(&path) {
return next.run(request).await;
}
// Check if admin exists
match state.setup_service.has_admin_users().await {
Ok(true) => {
// Admin exists, serve normal app
next.run(request).await
}
Ok(false) => {
// No admin, serve setup page
tracing::info!("Serving setup page - no admin user exists");
Html(SETUP_PAGE_HTML).into_response()
}
Err(e) => {
// Error checking, log and serve error
tracing::error!("Failed to check admin status: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error",
)
.into_response()
}
}
}

View File

@@ -2,6 +2,7 @@
pub mod middleware; pub mod middleware;
pub mod routes; pub mod routes;
pub mod setup_page;
pub mod v1; pub mod v1;
pub mod websocket; pub mod websocket;
@@ -13,6 +14,8 @@ use utoipa::OpenApi;
paths( paths(
routes::health_check, routes::health_check,
routes::login, routes::login,
routes::get_setup_status,
routes::setup_admin,
v1::organizations::list, v1::organizations::list,
v1::organizations::create, v1::organizations::create,
v1::organizations::get, v1::organizations::get,
@@ -47,6 +50,9 @@ use utoipa::OpenApi;
schemas( schemas(
routes::LoginRequest, routes::LoginRequest,
routes::LoginResponse, routes::LoginResponse,
routes::SetupStatus,
routes::SetupAdminRequest,
routes::SetupAdminResponse,
v1::organizations::OrganizationResponse, v1::organizations::OrganizationResponse,
v1::organizations::CreateOrganizationRequest, v1::organizations::CreateOrganizationRequest,
v1::workspaces::WorkspaceResponse, v1::workspaces::WorkspaceResponse,

View File

@@ -1,21 +1,29 @@
//! API route definitions //! API route definitions
use axum::{ use axum::{
extract::State,
middleware,
routing::{get, post}, routing::{get, post},
Router, Json, Router,
}; };
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use crate::api::middleware::auth::AuthState;
use crate::api::middleware::setup_check::setup_check_middleware;
use crate::api::v1;
use crate::api::ApiDoc;
use crate::config::Settings; use crate::config::Settings;
use crate::db::Database; use crate::db::Database;
use crate::api::middleware::auth::AuthState; use crate::services::auth_service::{AuthError, AuthService, UserInfo};
use crate::api::ApiDoc; use crate::services::setup_service::{SetupError, SetupService};
use crate::api::v1;
// Re-export SetupStatus for OpenAPI
pub use crate::services::setup_service::SetupStatus;
use super::middleware::auth::auth_middleware; use super::middleware::auth::auth_middleware;
use super::middleware::log_request; use super::middleware::log_request;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi; use utoipa_swagger_ui::SwaggerUi;
/// Application state /// Application state
@@ -23,67 +31,351 @@ use utoipa_swagger_ui::SwaggerUi;
pub struct AppState { pub struct AppState {
pub db: Database, pub db: Database,
pub settings: Arc<Settings>, pub settings: Arc<Settings>,
pub auth_service: AuthService,
pub setup_service: SetupService,
}
impl AppState {
/// Create a new app state
pub fn new(db: Database, settings: Arc<Settings>) -> Self {
let auth_service = AuthService::new(db.conn().clone(), settings.clone());
let setup_service = SetupService::new(db.conn().clone());
Self {
db,
settings,
auth_service,
setup_service,
}
}
} }
/// Create the main API router /// Create the main API router
pub fn create_router(state: AppState) -> Router { pub fn create_router(state: AppState) -> Router {
let auth_state = AuthState::new(state.settings.clone()); let auth_state = AuthState::new(state.settings.clone());
// Build CORS layer from settings
let cors_layer = build_cors_layer(&state.settings.cors);
// Public routes (no auth required) // Public routes (no auth required)
let public_routes = Router::new() let public_routes = Router::new()
.route("/health", get(health_check)) .route("/health", get(health_check))
// Setup routes (only available when no admin exists)
.route("/api/v1/auth/setup-status", get(get_setup_status))
.route("/api/v1/auth/setup", post(setup_admin))
// Auth routes
.route("/api/v1/auth/login", post(login)) .route("/api/v1/auth/login", post(login))
// Swagger UI (includes OpenAPI spec at /api/openapi.json) // Swagger UI (includes OpenAPI spec at /api/openapi.json)
.merge(SwaggerUi::new("/swagger-ui").url("/api/openapi.json", ApiDoc::generate())); .merge(SwaggerUi::new("/swagger-ui").url("/api/openapi.json", ApiDoc::generate()));
// Protected routes (auth required) // Protected routes (auth required)
let protected_routes = Router::new() let protected_routes = Router::new()
// User info
.route("/api/v1/auth/me", get(get_current_user))
// Organizations // Organizations
.route("/api/v1/organizations", get(v1::organizations::list).post(v1::organizations::create)) .route(
.route("/api/v1/organizations/:id", get(v1::organizations::get).patch(v1::organizations::update).delete(v1::organizations::delete)) "/api/v1/organizations",
.route("/api/v1/organizations/:id/workspaces", get(v1::workspaces::list).post(v1::workspaces::create)) get(v1::organizations::list).post(v1::organizations::create),
)
.route(
"/api/v1/organizations/:id",
get(v1::organizations::get)
.patch(v1::organizations::update)
.delete(v1::organizations::delete),
)
.route(
"/api/v1/organizations/:id/workspaces",
get(v1::workspaces::list).post(v1::workspaces::create),
)
// Workspaces // Workspaces
.route("/api/v1/workspaces/:id", get(v1::workspaces::get).patch(v1::workspaces::update).delete(v1::workspaces::delete)) .route(
"/api/v1/workspaces/:id",
get(v1::workspaces::get)
.patch(v1::workspaces::update)
.delete(v1::workspaces::delete),
)
.route("/api/v1/workspaces/:id/agents", get(v1::agents::list)) .route("/api/v1/workspaces/:id/agents", get(v1::agents::list))
.route("/api/v1/workspaces/:id/virtual-hosts", get(v1::virtual_hosts::list).post(v1::virtual_hosts::create)) .route(
.route("/api/v1/workspaces/:id/upstreams", get(v1::upstreams::list).post(v1::upstreams::create)) "/api/v1/workspaces/:id/virtual-hosts",
.route("/api/v1/workspaces/:id/certificates", get(v1::certificates::list).post(v1::certificates::create)) get(v1::virtual_hosts::list).post(v1::virtual_hosts::create),
)
.route(
"/api/v1/workspaces/:id/upstreams",
get(v1::upstreams::list).post(v1::upstreams::create),
)
.route(
"/api/v1/workspaces/:id/certificates",
get(v1::certificates::list).post(v1::certificates::create),
)
// Agents // Agents
.route("/api/v1/agents/:id", get(v1::agents::get).delete(v1::agents::delete)) .route(
"/api/v1/agents/:id",
get(v1::agents::get).delete(v1::agents::delete),
)
.route("/api/v1/agents/:id/reload", post(v1::agents::reload)) .route("/api/v1/agents/:id/reload", post(v1::agents::reload))
.route("/api/v1/workspaces/:id/agents/tokens", post(v1::agents::create_token)) .route(
"/api/v1/workspaces/:id/agents/tokens",
post(v1::agents::create_token),
)
// Virtual Hosts // Virtual Hosts
.route("/api/v1/virtual-hosts/:id", get(v1::virtual_hosts::get).patch(v1::virtual_hosts::update).delete(v1::virtual_hosts::delete)) .route(
"/api/v1/virtual-hosts/:id",
get(v1::virtual_hosts::get)
.patch(v1::virtual_hosts::update)
.delete(v1::virtual_hosts::delete),
)
// Upstreams // Upstreams
.route("/api/v1/upstreams/:id", get(v1::upstreams::get).patch(v1::upstreams::update).delete(v1::upstreams::delete)) .route(
"/api/v1/upstreams/:id",
get(v1::upstreams::get)
.patch(v1::upstreams::update)
.delete(v1::upstreams::delete),
)
// Certificates // Certificates
.route("/api/v1/certificates/:id", get(v1::certificates::get).delete(v1::certificates::delete)) .route(
"/api/v1/certificates/:id",
get(v1::certificates::get).delete(v1::certificates::delete),
)
// Middleware layer for protected routes // Middleware layer for protected routes
.layer(axum::middleware::from_fn_with_state(auth_state.clone(), auth_middleware)); .layer(axum::middleware::from_fn_with_state(
auth_state.clone(),
auth_middleware,
));
Router::new() Router::new()
.merge(public_routes) .merge(public_routes)
.merge(protected_routes) .merge(protected_routes)
// Setup check middleware - serves setup page when no admin exists
.layer(middleware::from_fn_with_state(
state.clone(),
setup_check_middleware,
))
.layer(axum::middleware::from_fn(log_request)) .layer(axum::middleware::from_fn(log_request))
.layer(cors_layer)
.with_state(state) .with_state(state)
} }
/// Build CORS layer from settings
fn build_cors_layer(cors_settings: &crate::config::CorsSettings) -> CorsLayer {
let mut cors = CorsLayer::new();
// Configure allowed origins
if cors_settings.allowed_origins.contains(&"*".to_string()) {
cors = cors.allow_origin(AllowOrigin::mirror_request());
} else {
let origins: Vec<_> = cors_settings
.allowed_origins
.iter()
.filter_map(|origin| origin.parse().ok())
.collect();
if !origins.is_empty() {
cors = cors.allow_origin(origins);
}
}
// Configure allowed methods
let methods: Vec<_> = cors_settings
.allowed_methods
.iter()
.filter_map(|method| method.parse().ok())
.collect();
if !methods.is_empty() {
cors = cors.allow_methods(methods);
}
// Configure allowed headers
let headers: Vec<_> = cors_settings
.allowed_headers
.iter()
.filter_map(|header| header.parse().ok())
.collect();
if !headers.is_empty() {
cors = cors.allow_headers(headers);
}
// Configure credentials
cors = cors.allow_credentials(cors_settings.allow_credentials);
cors
}
// ============================================================================
// Health Check
// ============================================================================
/// Health check endpoint /// Health check endpoint
#[utoipa::path( #[utoipa::path(
get, get,
path = "/health", path = "/health",
responses( responses(
(status = 200, description = "Server is healthy", body = String), (status = 200, description = "Server is healthy", body = HealthResponse),
) )
)] )]
async fn health_check() -> &'static str { async fn health_check() -> Json<serde_json::Value> {
"OK" Json(serde_json::json!({
"status": "healthy",
"version": env!("CARGO_PKG_VERSION"),
}))
}
// ============================================================================
// Setup Endpoints
// ============================================================================
/// Get setup status endpoint
#[utoipa::path(
get,
path = "/api/v1/auth/setup-status",
operation_id = "getSetupStatus",
tag = "Setup",
responses(
(status = 200, description = "Setup status retrieved", body = SetupStatus),
)
)]
async fn get_setup_status(
State(state): State<AppState>,
) -> Result<Json<SetupStatus>, (axum::http::StatusCode, Json<serde_json::Value>)> {
match state.setup_service.get_setup_status().await {
Ok(status) => Ok(Json(status)),
Err(e) => {
tracing::error!("Failed to get setup status: {}", e);
Err((
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to retrieve setup status"
})),
))
}
}
}
/// Setup admin request
#[derive(utoipa::ToSchema, serde::Deserialize)]
pub struct SetupAdminRequest {
/// Setup token (from server logs)
pub token: String,
/// Admin email
pub email: String,
/// Admin password (min 8 characters)
pub password: String,
/// Admin name
pub name: String,
}
/// Setup admin response
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct SetupAdminResponse {
pub message: String,
pub email: String,
}
/// Setup admin endpoint
#[utoipa::path(
post,
path = "/api/v1/auth/setup",
operation_id = "setupAdmin",
tag = "Setup",
request_body = SetupAdminRequest,
responses(
(status = 201, description = "Admin created successfully", body = SetupAdminResponse),
(status = 400, description = "Invalid input"),
(status = 403, description = "Setup already completed or invalid token"),
)
)]
async fn setup_admin(
State(state): State<AppState>,
Json(req): Json<SetupAdminRequest>,
) -> Result<
(axum::http::StatusCode, Json<SetupAdminResponse>),
(axum::http::StatusCode, Json<serde_json::Value>),
> {
match state
.setup_service
.create_initial_admin(&req.token, &req.email, &req.password, &req.name)
.await
{
Ok(user) => Ok((
axum::http::StatusCode::CREATED,
Json(SetupAdminResponse {
message: "Initial admin created successfully".to_string(),
email: user.email,
}),
)),
Err(SetupError::AlreadyCompleted) => Err((
axum::http::StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "Setup has already been completed"
})),
)),
Err(SetupError::InvalidToken | SetupError::TokenAlreadyUsed) => Err((
axum::http::StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "Invalid or expired setup token"
})),
)),
Err(SetupError::PasswordHashError(msg)) => Err((
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": msg
})),
)),
Err(e) => {
tracing::error!("Setup error: {}", e);
Err((
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error"
})),
))
}
}
}
// ============================================================================
// Auth Endpoints
// ============================================================================
/// Login request
#[derive(utoipa::ToSchema, serde::Deserialize)]
pub struct LoginRequest {
/// User email
pub email: String,
/// User password
pub password: String,
}
/// Login response
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct LoginResponse {
/// JWT access token
pub token: String,
/// Token type (always "Bearer")
pub token_type: String,
/// Expiration time in seconds
pub expires_in: u64,
/// User information
pub user: UserResponse,
}
/// User response
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct UserResponse {
pub id: String,
pub email: String,
pub name: Option<String>,
pub role: String,
pub organization_id: Option<String>,
}
impl From<UserInfo> for UserResponse {
fn from(user: UserInfo) -> Self {
Self {
id: user.id.to_string(),
email: user.email,
name: user.name,
role: user.role,
organization_id: user.organization_id.map(|id| id.to_string()),
}
}
} }
/// Login endpoint /// Login endpoint
@@ -91,24 +383,136 @@ async fn health_check() -> &'static str {
post, post,
path = "/api/v1/auth/login", path = "/api/v1/auth/login",
operation_id = "login", operation_id = "login",
tag = "Authentication",
request_body = LoginRequest, request_body = LoginRequest,
responses( responses(
(status = 200, description = "Login successful", body = LoginResponse), (status = 200, description = "Login successful", body = LoginResponse),
(status = 401, description = "Invalid credentials"),
(status = 403, description = "Setup required - no admin exists"),
) )
)] )]
async fn login() -> axum::Json<serde_json::Value> { async fn login(
axum::Json(serde_json::json!({ State(state): State<AppState>,
"token": "placeholder_token" Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (axum::http::StatusCode, Json<serde_json::Value>)> {
// Check if setup is required
match state.setup_service.has_admin_users().await {
Ok(false) => {
return Err((
axum::http::StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "Setup required",
"code": "SETUP_REQUIRED",
"message": "No admin user exists. Please complete initial setup."
})),
));
}
Err(e) => {
tracing::error!("Failed to check setup status: {}", e);
return Err((
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error"
})),
));
}
Ok(true) => {}
}
match state.auth_service.login(&req.email, &req.password).await {
Ok(result) => {
let expires_in = state.settings.auth.jwt_expiration_hours * 3600;
Ok(Json(LoginResponse {
token: result.token,
token_type: "Bearer".to_string(),
expires_in,
user: result.user.into(),
})) }))
} }
Err(AuthError::InvalidCredentials | AuthError::UserNotFound) => Err((
#[derive(utoipa::ToSchema, serde::Deserialize)] axum::http::StatusCode::UNAUTHORIZED,
pub struct LoginRequest { Json(serde_json::json!({
email: String, "error": "Invalid email or password"
password: String, })),
)),
Err(e) => {
tracing::error!("Login error: {}", e);
Err((
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error"
})),
))
}
}
} }
/// Get current user endpoint
#[utoipa::path(
get,
path = "/api/v1/auth/me",
operation_id = "getCurrentUser",
tag = "Authentication",
responses(
(status = 200, description = "Current user information", body = UserResponse),
(status = 401, description = "Unauthorized"),
)
)]
async fn get_current_user(
State(state): State<AppState>,
request: axum::extract::Request,
) -> Result<Json<UserResponse>, (axum::http::StatusCode, Json<serde_json::Value>)> {
// Extract claims from request extensions
let claims = request
.extensions()
.get::<crate::api::middleware::auth::Claims>()
.cloned();
match claims {
Some(claims) => {
let user_id = match uuid::Uuid::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return Err((
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Invalid user ID in token"
})),
));
}
};
match state.auth_service.get_user(user_id).await {
Ok(Some(user)) => Ok(Json(user.into())),
Ok(None) => Err((
axum::http::StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "User not found"
})),
)),
Err(e) => {
tracing::error!("Get user error: {}", e);
Err((
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error"
})),
))
}
}
}
None => Err((
axum::http::StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Unauthorized"
})),
)),
}
}
/// Health response
#[derive(utoipa::ToSchema, serde::Serialize)] #[derive(utoipa::ToSchema, serde::Serialize)]
pub struct LoginResponse { pub struct HealthResponse {
token: String, pub status: String,
pub version: String,
} }

View File

@@ -0,0 +1,397 @@
//! Static HTML setup page for initial admin creation
//!
//! This page is served when no admin user exists in the system.
//! It's a self-contained HTML page with embedded CSS and JavaScript.
/// The setup page HTML template
pub const SETUP_PAGE_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NxMesh - Initial Setup</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 480px;
padding: 40px;
}
.header {
text-align: center;
margin-bottom: 32px;
}
.logo {
width: 64px;
height: 64px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.logo svg {
width: 36px;
height: 36px;
fill: white;
}
h1 {
font-size: 28px;
font-weight: 700;
color: #1a202c;
margin-bottom: 8px;
}
.subtitle {
color: #718096;
font-size: 14px;
}
.alert {
background: #fffaf0;
border: 1px solid #fbd38d;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
display: flex;
gap: 12px;
align-items: flex-start;
}
.alert-icon {
width: 20px;
height: 20px;
color: #dd6b20;
flex-shrink: 0;
}
.alert-content h3 {
font-size: 14px;
font-weight: 600;
color: #c05621;
margin-bottom: 4px;
}
.alert-content p {
font-size: 13px;
color: #9c4221;
line-height: 1.5;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: #4a5568;
margin-bottom: 6px;
}
input {
width: 100%;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
transition: all 0.2s;
font-family: inherit;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
input.error {
border-color: #fc8181;
}
.hint {
font-size: 12px;
color: #a0aec0;
margin-top: 4px;
}
.error-message {
background: #fed7d7;
border: 1px solid #fc8181;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 20px;
color: #c53030;
font-size: 14px;
display: none;
}
.error-message.show {
display: block;
}
button {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.success {
text-align: center;
padding: 40px 20px;
}
.success-icon {
width: 80px;
height: 80px;
background: #c6f6d5;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.success-icon svg {
width: 40px;
height: 40px;
color: #38a169;
}
.success h2 {
font-size: 24px;
color: #1a202c;
margin-bottom: 12px;
}
.success p {
color: #718096;
margin-bottom: 24px;
}
.btn-secondary {
background: #edf2f7;
color: #4a5568;
}
.btn-secondary:hover {
background: #e2e8f0;
box-shadow: none;
}
</style>
</head>
<body>
<div class="container">
<div id="setup-form">
<div class="header">
<div class="logo">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
</svg>
</div>
<h1>Initial Setup</h1>
<p class="subtitle">Create the first admin account</p>
</div>
<div class="alert">
<svg class="alert-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
<div class="alert-content">
<h3>Setup Token Required</h3>
<p>Please check the server console/logs for the setup token. It was displayed when the server started.</p>
</div>
</div>
<div class="error-message" id="error-message"></div>
<form id="form">
<div class="form-group">
<label for="token">Setup Token</label>
<input type="text" id="token" name="token" placeholder="nxm_xxxxxxxx..." required autocomplete="off">
<p class="hint">Found in server console/logs</p>
</div>
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" name="name" placeholder="Administrator" required>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="admin@example.com" required autocomplete="email">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="••••••••" required minlength="8" autocomplete="new-password">
<p class="hint">Must be at least 8 characters</p>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input type="password" id="confirmPassword" name="confirmPassword" placeholder="••••••••" required autocomplete="new-password">
</div>
<button type="submit" id="submit-btn">
Create Admin Account
</button>
</form>
</div>
<div id="success-view" style="display: none;">
<div class="success">
<div class="success-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h2>Setup Complete!</h2>
<p>Admin account created successfully. You can now log in.</p>
<button onclick="window.location.href='/login'" class="btn-secondary">
Go to Login
</button>
</div>
</div>
</div>
<script>
const form = document.getElementById('form');
const errorDiv = document.getElementById('error-message');
const submitBtn = document.getElementById('submit-btn');
const setupForm = document.getElementById('setup-form');
const successView = document.getElementById('success-view');
function showError(message) {
errorDiv.textContent = message;
errorDiv.classList.add('show');
}
function hideError() {
errorDiv.classList.remove('show');
}
function setLoading(loading) {
submitBtn.disabled = loading;
if (loading) {
submitBtn.innerHTML = '<div class="spinner"></div> Creating...';
} else {
submitBtn.textContent = 'Create Admin Account';
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
const formData = new FormData(form);
const data = Object.fromEntries(formData);
// Validation
if (data.password !== data.confirmPassword) {
showError('Passwords do not match');
return;
}
if (data.password.length < 8) {
showError('Password must be at least 8 characters');
return;
}
if (!data.token.startsWith('nxm_')) {
showError('Invalid setup token format');
return;
}
setLoading(true);
try {
const response = await fetch('/api/v1/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: data.token,
email: data.email,
password: data.password,
name: data.name
})
});
const result = await response.json();
if (!response.ok) {
showError(result.error || 'Failed to create admin account');
return;
}
// Show success view
setupForm.style.display = 'none';
successView.style.display = 'block';
} catch (err) {
showError('Network error. Please try again.');
} finally {
setLoading(false);
}
});
</script>
</body>
</html>"#;

View File

@@ -2,4 +2,4 @@
pub mod settings; pub mod settings;
pub use settings::Settings; pub use settings::{CorsSettings, Settings};

View File

@@ -10,6 +10,8 @@ pub struct Settings {
pub database: DatabaseSettings, pub database: DatabaseSettings,
pub grpc: GrpcSettings, pub grpc: GrpcSettings,
pub auth: AuthSettings, pub auth: AuthSettings,
#[serde(default)]
pub cors: CorsSettings,
} }
/// HTTP server settings /// HTTP server settings
@@ -40,6 +42,19 @@ pub struct AuthSettings {
pub jwt_expiration_hours: u64, pub jwt_expiration_hours: u64,
} }
/// CORS settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorsSettings {
/// Allowed origins (default: any)
pub allowed_origins: Vec<String>,
/// Allowed methods (default: GET, POST, PUT, DELETE, PATCH, OPTIONS)
pub allowed_methods: Vec<String>,
/// Allowed headers (default: Content-Type, Authorization)
pub allowed_headers: Vec<String>,
/// Allow credentials (default: true)
pub allow_credentials: bool,
}
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -59,6 +74,28 @@ impl Default for Settings {
jwt_secret: "change-me-in-production".to_string(), jwt_secret: "change-me-in-production".to_string(),
jwt_expiration_hours: 24, jwt_expiration_hours: 24,
}, },
cors: CorsSettings::default(),
}
}
}
impl Default for CorsSettings {
fn default() -> Self {
Self {
allowed_origins: vec!["*".to_string()],
allowed_methods: vec![
"GET".to_string(),
"POST".to_string(),
"PUT".to_string(),
"DELETE".to_string(),
"PATCH".to_string(),
"OPTIONS".to_string(),
],
allowed_headers: vec![
"Content-Type".to_string(),
"Authorization".to_string(),
],
allow_credentials: true,
} }
} }
} }

View File

@@ -2,4 +2,5 @@
pub mod agent_repository; pub mod agent_repository;
pub mod organization_repository; pub mod organization_repository;
pub mod user_repository;
pub mod workspace_repository; pub mod workspace_repository;

View File

@@ -0,0 +1,92 @@
//! User repository
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
};
use uuid::Uuid;
use crate::db::entities::users::{self, ActiveModel, Model};
/// User repository
#[derive(Clone)]
pub struct UserRepository {
db: DatabaseConnection,
}
impl UserRepository {
/// Create a new repository instance
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
/// Find user by ID
pub async fn find_by_id(&self, id: Uuid) -> Result<Option<Model>, sea_orm::DbErr> {
users::Entity::find_by_id(id).one(&self.db).await
}
/// Find user by email
pub async fn find_by_email(&self, email: &str) -> Result<Option<Model>, sea_orm::DbErr> {
users::Entity::find()
.filter(users::Column::Email.eq(email))
.one(&self.db)
.await
}
/// Create a new user
pub async fn create(
&self,
email: &str,
password_hash: &str,
name: Option<&str>,
role: &str,
organization_id: Option<Uuid>,
) -> Result<Model, sea_orm::DbErr> {
let now = chrono::Utc::now();
let user = ActiveModel {
id: Set(Uuid::new_v4()),
email: Set(email.to_string()),
password_hash: Set(password_hash.to_string()),
name: Set(name.map(|s| s.to_string())),
role: Set(role.to_string()),
organization_id: Set(organization_id),
created_at: Set(now.into()),
updated_at: Set(now.into()),
};
user.insert(&self.db).await
}
/// Update user's password
pub async fn update_password(
&self,
id: Uuid,
password_hash: &str,
) -> Result<Model, sea_orm::DbErr> {
let now = chrono::Utc::now();
let user = ActiveModel {
id: Set(id),
password_hash: Set(password_hash.to_string()),
updated_at: Set(now.into()),
..Default::default()
};
user.update(&self.db).await
}
/// List all users in an organization
pub async fn list_by_organization(
&self,
org_id: Uuid,
) -> Result<Vec<Model>, sea_orm::DbErr> {
users::Entity::find()
.filter(users::Column::OrganizationId.eq(org_id))
.all(&self.db)
.await
}
/// Delete a user
pub async fn delete(&self, id: Uuid) -> Result<u64, sea_orm::DbErr> {
let result = users::Entity::delete_by_id(id).exec(&self.db).await?;
Ok(result.rows_affected)
}
}

View File

@@ -37,10 +37,42 @@ pub async fn start(settings: Settings) -> Result<(), Box<dyn std::error::Error>>
info!("Database migrations complete"); info!("Database migrations complete");
// Create application state // Create application state
let app_state = api::routes::AppState { let app_state = api::routes::AppState::new(db.clone(), settings.clone());
db: db.clone(),
settings: settings.clone(), // Generate setup token if no admin exists
}; match app_state.setup_service.has_admin_users().await {
Ok(false) => {
info!("=================================================================");
info!(" INITIAL SETUP REQUIRED ");
info!("=================================================================");
info!("No admin user found. A setup token has been generated.");
info!("");
match app_state.setup_service.generate_setup_token().await {
Ok(token_info) => {
info!("SETUP TOKEN: {}", token_info.plain_token);
info!("EXPIRES AT: {}", token_info.expires_at);
info!("");
info!("Use this token to create the first admin account at:");
info!(" http://{}:{}/setup", settings.server.bind_address, settings.server.port);
info!("");
info!("WARNING: This token is single-use and will expire in 24 hours.");
info!(" It is displayed only once in these logs.");
}
Err(e) => {
error!("Failed to generate setup token: {}", e);
}
}
info!("=================================================================");
}
Ok(true) => {
info!("Admin user exists - initial setup already completed");
}
Err(e) => {
error!("Failed to check admin status: {}", e);
}
}
// Create router // Create router
let app = api::routes::create_router(app_state); let app = api::routes::create_router(app_state);

View File

@@ -1,25 +1,259 @@
//! Authentication service //! Authentication service
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::api::middleware::auth::{generate_token, Claims};
use crate::config::Settings;
use crate::db::repositories::user_repository::UserRepository;
/// Auth errors
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Invalid credentials")]
InvalidCredentials,
#[error("User not found")]
UserNotFound,
#[error("Password hash error: {0}")]
PasswordHashError(String),
#[error("Token generation failed: {0}")]
TokenError(String),
#[error("Database error: {0}")]
DatabaseError(#[from] sea_orm::DbErr),
}
/// Login result
#[derive(Debug, Clone)]
pub struct LoginResult {
pub token: String,
pub user: UserInfo,
}
/// User information
#[derive(Debug, Clone)]
pub struct UserInfo {
pub id: Uuid,
pub email: String,
pub name: Option<String>,
pub role: String,
pub organization_id: Option<Uuid>,
}
/// Auth service /// Auth service
pub struct AuthService; #[derive(Clone)]
pub struct AuthService {
user_repo: UserRepository,
settings: Arc<Settings>,
}
impl AuthService { impl AuthService {
/// Create a new service instance /// Create a new service instance
pub fn new() -> Self { pub fn new(db: DatabaseConnection, settings: Arc<Settings>) -> Self {
Self Self {
user_repo: UserRepository::new(db),
settings,
}
} }
/// Authenticate user /// Register a new user
pub async fn authenticate(&self, email: &str, password: &str) -> Result<String, String> { pub async fn register(
// TODO: Implement &self,
tracing::info!("Authenticating user: {}", email); email: &str,
Ok("jwt_token_placeholder".to_string()) password: &str,
name: Option<&str>,
role: &str,
organization_id: Option<Uuid>,
) -> Result<UserInfo, AuthError> {
info!("Registering new user: {}", email);
// Check if user already exists
if self.user_repo.find_by_email(email).await?.is_some() {
warn!("User already exists: {}", email);
return Err(AuthError::InvalidCredentials);
} }
/// Validate token // Hash password
pub async fn validate_token(&self, token: &str) -> Result<bool, String> { let password_hash = hash_password(password)?;
// TODO: Implement
tracing::info!("Validating token: {}", token); // Create user
Ok(true) let user = self
.user_repo
.create(email, &password_hash, name, role, organization_id)
.await?;
info!("User registered successfully: {}", user.id);
Ok(UserInfo {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
organization_id: user.organization_id,
})
}
/// Authenticate user and generate JWT token
pub async fn login(&self, email: &str, password: &str) -> Result<LoginResult, AuthError> {
info!("Authenticating user: {}", email);
// Find user by email
let user = self
.user_repo
.find_by_email(email)
.await?
.ok_or(AuthError::UserNotFound)?;
// Verify password
if !verify_password(password, &user.password_hash)? {
warn!("Invalid password for user: {}", email);
return Err(AuthError::InvalidCredentials);
}
// Generate JWT token
let org_id_str = user.organization_id.map(|id| id.to_string());
let token = generate_token(
&user.id.to_string(),
&user.email,
org_id_str.as_deref(),
&user.role,
&self.settings,
)
.map_err(|e| AuthError::TokenError(e.to_string()))?;
info!("User authenticated successfully: {}", user.id);
Ok(LoginResult {
token,
user: UserInfo {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
organization_id: user.organization_id,
},
})
}
/// Validate token and return claims
pub fn validate_token(&self, token: &str) -> Result<Claims, AuthError> {
crate::api::middleware::auth::validate_token(token, &self.settings)
.map_err(|_| AuthError::InvalidCredentials)
}
/// Get user by ID
pub async fn get_user(&self, id: Uuid) -> Result<Option<UserInfo>, AuthError> {
let user = self.user_repo.find_by_id(id).await?;
Ok(user.map(|u| UserInfo {
id: u.id,
email: u.email,
name: u.name,
role: u.role,
organization_id: u.organization_id,
}))
}
/// Change user password
pub async fn change_password(
&self,
user_id: Uuid,
old_password: &str,
new_password: &str,
) -> Result<(), AuthError> {
// Get user
let user = self
.user_repo
.find_by_id(user_id)
.await?
.ok_or(AuthError::UserNotFound)?;
// Verify old password
if !verify_password(old_password, &user.password_hash)? {
return Err(AuthError::InvalidCredentials);
}
// Hash new password
let new_hash = hash_password(new_password)?;
// Update password
self.user_repo.update_password(user_id, &new_hash).await?;
info!("Password changed for user: {}", user_id);
Ok(())
}
/// Create initial admin user if no users exist
pub async fn create_initial_admin(&self) -> Result<Option<UserInfo>, AuthError> {
// Check if any users exist
// For simplicity, we just try to find by a specific admin email
let admin_email = "admin@nxmesh.local";
if self.user_repo.find_by_email(admin_email).await?.is_some() {
return Ok(None);
}
info!("Creating initial admin user");
let user = self
.register(
admin_email,
"admin123", // Default password - should be changed on first login
Some("Administrator"),
"admin",
None,
)
.await?;
info!(
"Initial admin user created: {} / password: admin123",
admin_email
);
info!("IMPORTANT: Please change the default password immediately!");
Ok(Some(user))
}
}
/// Hash a password using Argon2
fn hash_password(password: &str) -> Result<String, AuthError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|e| AuthError::PasswordHashError(e.to_string()))
}
/// Verify a password against a hash
fn verify_password(password: &str, hash: &str) -> Result<bool, AuthError> {
let parsed_hash =
PasswordHash::new(hash).map_err(|e| AuthError::PasswordHashError(e.to_string()))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_password_hashing() {
let password = "test_password_123";
let hash = hash_password(password).unwrap();
// Verify correct password
assert!(verify_password(password, &hash).unwrap());
// Verify wrong password fails
assert!(!verify_password("wrong_password", &hash).unwrap());
} }
} }

View File

@@ -5,4 +5,5 @@ pub mod auth_service;
pub mod certificate_service; pub mod certificate_service;
pub mod config_service; pub mod config_service;
pub mod organization_service; pub mod organization_service;
pub mod setup_service;
pub mod workspace_service; pub mod workspace_service;

View File

@@ -0,0 +1,344 @@
//! Initial setup service for production-ready admin creation
//!
//! This service handles the secure creation of the first admin user
//! using a one-time setup token system.
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use rand::{rngs::OsRng, Rng};
use chrono::{Duration, Utc};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, Set,
};
use sha2::{Digest, Sha256};
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::db::entities::{setup_tokens, users};
/// Setup errors
#[derive(Debug, thiserror::Error)]
pub enum SetupError {
#[error("Setup already completed")]
AlreadyCompleted,
#[error("Invalid or expired setup token")]
InvalidToken,
#[error("Token has already been used")]
TokenAlreadyUsed,
#[error("Password hash error: {0}")]
PasswordHashError(String),
#[error("Database error: {0}")]
DatabaseError(#[from] sea_orm::DbErr),
#[error("Token generation failed")]
TokenGenerationFailed,
}
/// Setup token information (returned when creating a new token)
#[derive(Debug, Clone)]
pub struct SetupTokenInfo {
/// The plain text token (shown only once to user)
pub plain_token: String,
/// Expiration time
pub expires_at: chrono::DateTime<Utc>,
}
/// Setup status response
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
pub struct SetupStatus {
/// Whether initial setup is required
pub setup_required: bool,
/// Whether a valid setup token exists
pub has_valid_token: bool,
/// Token expiration time (if exists)
pub token_expires_at: Option<chrono::DateTime<Utc>>,
}
/// Setup service
#[derive(Clone)]
pub struct SetupService {
db: DatabaseConnection,
}
impl SetupService {
/// Create a new setup service
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
/// Check if any admin users exist
pub async fn has_admin_users(&self) -> Result<bool, SetupError> {
let count = users::Entity::find()
.filter(users::Column::Role.eq("admin"))
.count(&self.db)
.await?;
Ok(count > 0)
}
/// Get setup status
pub async fn get_setup_status(&self) -> Result<SetupStatus, SetupError> {
let setup_required = !self.has_admin_users().await?;
if !setup_required {
return Ok(SetupStatus {
setup_required: false,
has_valid_token: false,
token_expires_at: None,
});
}
// Check for valid, unused token
let valid_token = setup_tokens::Entity::find()
.filter(setup_tokens::Column::UsedAt.is_null())
.filter(setup_tokens::Column::ExpiresAt.gt(Utc::now()))
.one(&self.db)
.await?;
Ok(SetupStatus {
setup_required: true,
has_valid_token: valid_token.is_some(),
token_expires_at: valid_token.map(|t| t.expires_at.into()),
})
}
/// Generate a new setup token
///
/// Returns the plain token (to be shown to user) and stores a hash
pub async fn generate_setup_token(&self) -> Result<SetupTokenInfo, SetupError> {
// Check if setup is already completed
if self.has_admin_users().await? {
return Err(SetupError::AlreadyCompleted);
}
// Check if a valid token already exists
let existing = setup_tokens::Entity::find()
.filter(setup_tokens::Column::UsedAt.is_null())
.filter(setup_tokens::Column::ExpiresAt.gt(Utc::now()))
.one(&self.db)
.await?;
if existing.is_some() {
warn!("A valid setup token already exists");
return Err(SetupError::AlreadyCompleted);
}
// Generate cryptographically secure random token
let plain_token = Self::generate_secure_token();
// Hash the token for storage (using SHA-256)
let token_hash = Self::hash_token(&plain_token);
// Token expires in 24 hours
let expires_at = Utc::now() + Duration::hours(24);
let created_at = Utc::now();
// Store in database
let token_model = setup_tokens::ActiveModel {
id: Set(Uuid::new_v4()),
token_hash: Set(token_hash),
expires_at: Set(expires_at.into()),
used_at: Set(None),
created_at: Set(created_at.into()),
};
token_model.insert(&self.db).await?;
info!("New setup token generated, expires at: {}", expires_at);
Ok(SetupTokenInfo {
plain_token,
expires_at,
})
}
/// Verify a setup token and mark it as used
async fn verify_and_consume_token(&self, plain_token: &str) -> Result<Uuid, SetupError> {
let token_hash = Self::hash_token(plain_token);
// Find the token
let token = setup_tokens::Entity::find()
.filter(setup_tokens::Column::TokenHash.eq(&token_hash))
.one(&self.db)
.await?;
let token = token.ok_or(SetupError::InvalidToken)?;
// Check if already used
if token.used_at.is_some() {
warn!("Attempt to reuse setup token");
return Err(SetupError::TokenAlreadyUsed);
}
// Check expiration
let now: chrono::DateTime<chrono::Utc> = Utc::now();
if token.expires_at < now {
warn!("Expired setup token used");
return Err(SetupError::InvalidToken);
}
// Mark as used
let mut token_active: setup_tokens::ActiveModel = token.clone().into();
token_active.used_at = Set(Some(Utc::now().into()));
token_active.update(&self.db).await?;
Ok(token.id)
}
/// Complete setup by creating the first admin user
pub async fn create_initial_admin(
&self,
setup_token: &str,
email: &str,
password: &str,
name: &str,
) -> Result<users::Model, SetupError> {
// Verify setup is still needed
if self.has_admin_users().await? {
return Err(SetupError::AlreadyCompleted);
}
// Verify and consume the setup token
self.verify_and_consume_token(setup_token).await?;
// Validate email format
if !Self::is_valid_email(email) {
return Err(SetupError::InvalidToken); // Reuse error for security
}
// Validate password strength
if password.len() < 8 {
return Err(SetupError::PasswordHashError(
"Password must be at least 8 characters".to_string(),
));
}
// Hash password with Argon2
let password_hash = Self::hash_password(password)?;
// Create admin user
let now = Utc::now();
let user = users::ActiveModel {
id: Set(Uuid::new_v4()),
email: Set(email.to_string()),
password_hash: Set(password_hash),
name: Set(Some(name.to_string())),
role: Set("admin".to_string()),
organization_id: Set(None), // Admin starts without org, can create one
created_at: Set(now.into()),
updated_at: Set(now.into()),
};
let user = user.insert(&self.db).await?;
info!(
"Initial admin user created successfully: {} ({})",
user.email, user.id
);
Ok(user)
}
/// Generate a cryptographically secure random token
fn generate_secure_token() -> String {
use rand::Rng;
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const TOKEN_LENGTH: usize = 64;
let mut rng = OsRng;
let token: String = (0..TOKEN_LENGTH)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect();
format!("nxm_{}", token)
}
/// Hash a token using SHA-256
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
/// Hash a password using Argon2
fn hash_password(password: &str) -> Result<String, SetupError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|e| SetupError::PasswordHashError(e.to_string()))
}
/// Basic email validation
fn is_valid_email(email: &str) -> bool {
// Simple regex-free validation
let parts: Vec<&str> = email.split('@').collect();
if parts.len() != 2 {
return false;
}
let local = parts[0];
let domain = parts[1];
!local.is_empty()
&& !domain.is_empty()
&& domain.contains('.')
&& !domain.starts_with('.')
&& !domain.ends_with('.')
}
/// Clean up expired tokens (can be called periodically)
pub async fn cleanup_expired_tokens(&self) -> Result<u64, SetupError> {
let result = setup_tokens::Entity::delete_many()
.filter(setup_tokens::Column::ExpiresAt.lt(Utc::now()))
.exec(&self.db)
.await?;
if result.rows_affected > 0 {
info!("Cleaned up {} expired setup tokens", result.rows_affected);
}
Ok(result.rows_affected)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_token_generation() {
let token = SetupService::generate_secure_token();
assert!(token.starts_with("nxm_"));
assert_eq!(token.len(), 68); // "nxm_" + 64 chars
}
#[test]
fn test_token_hashing() {
let token = "test_token_123";
let hash1 = SetupService::hash_token(token);
let hash2 = SetupService::hash_token(token);
// Same token should produce same hash
assert_eq!(hash1, hash2);
// Different token should produce different hash
let hash3 = SetupService::hash_token("different_token");
assert_ne!(hash1, hash3);
}
#[test]
fn test_email_validation() {
assert!(SetupService::is_valid_email("admin@example.com"));
assert!(SetupService::is_valid_email("user@sub.domain.com"));
assert!(!SetupService::is_valid_email("invalid"));
assert!(!SetupService::is_valid_email("@example.com"));
assert!(!SetupService::is_valid_email("user@"));
assert!(!SetupService::is_valid_email("user@.com"));
}
}

98
docs/setup.md Normal file
View File

@@ -0,0 +1,98 @@
# NxMesh Initial Setup
## Overview
NxMesh uses a secure, production-ready initial setup system for creating the first admin account. This ensures no default credentials exist in the system.
## How It Works
### Production Mode
1. **First Server Start**: When the server starts without any admin users, it generates a cryptographically secure setup token
2. **Token Display**: The token is displayed **only** in the server console/logs:
```
=================================================================
INITIAL SETUP REQUIRED
=================================================================
SETUP TOKEN: nxm_CbCa87uchQye6ZOWysnitrZC0F5mhrrJPcPmwDrq...
EXPIRES AT: 2026-03-04 07:19:50.840608304 UTC
=================================================================
```
3. **Access Setup Page**: Visit `http://localhost:8080/` and the setup page is automatically served
4. **Create Admin**: Enter the setup token and admin details
5. **Done**: After setup, the normal application is served
### Development Mode
In development (`just dev` or `bun dev`), the Vite dev server automatically checks setup status:
1. Visit `http://localhost:3000/` (frontend dev server)
2. The dev server checks setup status from backend
3. If setup required: Serves the setup page directly (proxied from backend)
4. If setup complete: Serves the React application
The dev experience is seamless - you don't need to switch ports.
## Security Features
| Feature | Description |
|---------|-------------|
| **Token Format** | 68-character random string: `nxm_` prefix + 64 chars |
| **Storage** | SHA-256 hash stored in database, plain token never saved |
| **Expiration** | 24-hour validity period |
| **Single Use** | Token invalidated after successful admin creation |
| **Console Only** | Token displayed only in server logs, not exposed via API |
| **Cryptographic RNG** | Uses `OsRng` for secure random generation |
## API Endpoints
### Check Setup Status
```bash
GET /api/v1/auth/setup-status
```
Response:
```json
{
"setup_required": true,
"has_valid_token": true,
"token_expires_at": "2026-03-04T07:19:50.840608Z"
}
```
### Complete Setup
```bash
POST /api/v1/auth/setup
Content-Type: application/json
{
"token": "nxm_...",
"email": "admin@example.com",
"password": "SecurePass123!",
"name": "Administrator"
}
```
## Troubleshooting
### Lost Setup Token
If you lose the setup token before completing setup:
1. **Wait 24 hours**: The token will expire automatically
2. **Clear database**: Remove the unused token from `setup_tokens` table
3. **Restart server**: A new token will be generated
### Setup Not Working in Dev Mode
1. Ensure backend is running on port 8080
2. Check browser console for errors
3. Try accessing `http://localhost:8080/` directly (backend URL)
### Already Completed Setup
Once setup is complete:
- The setup token is invalidated
- The setup page will no longer be served
- Normal login flow is required
- Use `/api/v1/auth/login` to authenticate

View File

@@ -1,22 +1,79 @@
import { Routes, Route } from 'react-router-dom' import { Routes, Route } from 'react-router-dom'
import { Layout } from './components/layout/Layout' import { Layout } from './components/layout/Layout'
import { ProtectedRoute } from './components/auth/ProtectedRoute'
import { PublicRoute } from './components/auth/PublicRoute'
import { Dashboard } from './pages/Dashboard/Dashboard' import { Dashboard } from './pages/Dashboard/Dashboard'
import { Agents } from './pages/Agents/Agents' import { Agents } from './pages/Agents/Agents'
import { Configurations } from './pages/Configurations/Configurations' import { Configurations } from './pages/Configurations/Configurations'
import { Certificates } from './pages/Certificates/Certificates' import { Certificates } from './pages/Certificates/Certificates'
import { Settings } from './pages/Settings/Settings' import { Settings } from './pages/Settings/Settings'
import { Login } from './pages/Login/Login'
function App() { function App() {
return ( return (
<Layout>
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> {/* Public routes */}
<Route path="/agents" element={<Agents />} /> <Route
<Route path="/configurations" element={<Configurations />} /> path="/login"
<Route path="/certificates" element={<Certificates />} /> element={
<Route path="/settings" element={<Settings />} /> <PublicRoute>
</Routes> <Login />
</PublicRoute>
}
/>
{/* Protected routes */}
<Route
path="/"
element={
<ProtectedRoute>
<Layout>
<Dashboard />
</Layout> </Layout>
</ProtectedRoute>
}
/>
<Route
path="/agents"
element={
<ProtectedRoute>
<Layout>
<Agents />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/configurations"
element={
<ProtectedRoute>
<Layout>
<Configurations />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/certificates"
element={
<ProtectedRoute>
<Layout>
<Certificates />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Layout>
<Settings />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
) )
} }

View File

@@ -23,22 +23,26 @@ client.use({
}); });
// Type aliases for request/response bodies // Type aliases for request/response bodies
type LoginRequest = components['schemas']['LoginRequest']; export type LoginRequest = components['schemas']['LoginRequest'];
type LoginResponse = components['schemas']['LoginResponse']; export type LoginResponse = components['schemas']['LoginResponse'];
type OrganizationResponse = components['schemas']['OrganizationResponse']; export type OrganizationResponse = components['schemas']['OrganizationResponse'];
type CreateOrganizationRequest = components['schemas']['CreateOrganizationRequest']; export type CreateOrganizationRequest = components['schemas']['CreateOrganizationRequest'];
type WorkspaceResponse = components['schemas']['WorkspaceResponse']; export type WorkspaceResponse = components['schemas']['WorkspaceResponse'];
type CreateWorkspaceRequest = components['schemas']['CreateWorkspaceRequest']; export type CreateWorkspaceRequest = components['schemas']['CreateWorkspaceRequest'];
type AgentResponse = components['schemas']['AgentResponse']; export type AgentResponse = components['schemas']['AgentResponse'];
type CreateAgentTokenRequest = components['schemas']['CreateAgentTokenRequest']; export type CreateAgentTokenRequest = components['schemas']['CreateAgentTokenRequest'];
type VirtualHostResponse = components['schemas']['VirtualHostResponse']; export type VirtualHostResponse = components['schemas']['VirtualHostResponse'];
type CreateVirtualHostRequest = components['schemas']['CreateVirtualHostRequest']; export type CreateVirtualHostRequest = components['schemas']['CreateVirtualHostRequest'];
type UpstreamResponse = components['schemas']['UpstreamResponse']; export type UpstreamResponse = components['schemas']['UpstreamResponse'];
type CreateUpstreamRequest = components['schemas']['CreateUpstreamRequest']; export type CreateUpstreamRequest = components['schemas']['CreateUpstreamRequest'];
type CertificateResponse = components['schemas']['CertificateResponse']; export type CertificateResponse = components['schemas']['CertificateResponse'];
type CreateCertificateRequest = components['schemas']['CreateCertificateRequest']; export type CreateCertificateRequest = components['schemas']['CreateCertificateRequest'];
export const api = { export const api = {
// Auth
login: (body: { email: string; password: string }) =>
client.POST('/api/v1/auth/login', { body }),
// Organizations // Organizations
listOrganizations: () => listOrganizations: () =>
client.GET('/api/v1/organizations'), client.GET('/api/v1/organizations'),
@@ -108,21 +112,6 @@ export const api = {
client.POST('/api/v1/workspaces/{id}/certificates', { params: { path: { id: wsId } }, body }), client.POST('/api/v1/workspaces/{id}/certificates', { params: { path: { id: wsId } }, body }),
deleteCertificate: (id: string) => deleteCertificate: (id: string) =>
client.DELETE('/api/v1/certificates/{id}', { params: { path: { id } } }), client.DELETE('/api/v1/certificates/{id}', { params: { path: { id } } }),
// Auth
login: (body: LoginRequest) =>
client.POST('/api/v1/auth/login', { body }),
}; };
export type { export type { paths, components };
components,
paths,
LoginRequest,
LoginResponse,
OrganizationResponse,
WorkspaceResponse,
AgentResponse,
VirtualHostResponse,
UpstreamResponse,
CertificateResponse,
};

View File

@@ -0,0 +1,18 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { token } = useAuthStore();
const location = useLocation();
// Not authenticated - redirect to login
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,17 @@
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
interface PublicRouteProps {
children: React.ReactNode;
}
export function PublicRoute({ children }: PublicRouteProps) {
const { token } = useAuthStore();
// Already authenticated - redirect to dashboard
if (token) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}

View File

@@ -1,6 +1,16 @@
import { Bell, User } from 'lucide-react' import { Bell, User, LogOut } from 'lucide-react';
import { useAuthStore } from '../../stores/authStore';
import { useNavigate } from 'react-router-dom';
export function Header() { export function Header() {
const navigate = useNavigate();
const { user, logout } = useAuthStore();
const handleLogout = () => {
logout();
navigate('/login');
};
return ( return (
<header className="bg-white dark:bg-gray-800 shadow-sm"> <header className="bg-white dark:bg-gray-800 shadow-sm">
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between px-6 py-4">
@@ -8,14 +18,36 @@ export function Header() {
NxMesh Admin NxMesh Admin
</h1> </h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Notifications */}
<button className="p-2 text-gray-600 hover:bg-gray-100 rounded-full dark:text-gray-300 dark:hover:bg-gray-700"> <button className="p-2 text-gray-600 hover:bg-gray-100 rounded-full dark:text-gray-300 dark:hover:bg-gray-700">
<Bell className="w-5 h-5" /> <Bell className="w-5 h-5" />
</button> </button>
<button className="flex items-center gap-2 p-2 text-gray-600 hover:bg-gray-100 rounded-full dark:text-gray-300 dark:hover:bg-gray-700">
<User className="w-5 h-5" /> {/* User Info */}
<div className="flex items-center gap-3">
<div className="text-right hidden sm:block">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{user?.name || user?.email}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 capitalize">
{user?.role}
</p>
</div>
<div className="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
</div>
{/* Logout Button */}
<button
onClick={handleLogout}
className="p-2 text-gray-600 hover:bg-red-100 hover:text-red-600 rounded-full dark:text-gray-300 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors"
title="Logout"
>
<LogOut className="w-5 h-5" />
</button> </button>
</div> </div>
</div> </div>
</header> </header>
) );
} }

View File

@@ -0,0 +1,138 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { Loader2, Shield, AlertCircle, ExternalLink } from 'lucide-react';
export function Login() {
const navigate = useNavigate();
const { login, isLoading, error, clearError } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
const success = await login(email, password);
if (success) {
navigate('/');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
{/* Header */}
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-xl bg-blue-600">
<Shield className="h-8 w-8 text-white" />
</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
Sign in to NxMesh
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Distributed Nginx Management System
</p>
</div>
{/* Error Message */}
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-300">
{error}
</h3>
</div>
</div>
</div>
)}
{/* Form */}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
placeholder="Email address"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
placeholder="Password"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
'Sign in'
)}
</button>
</div>
</form>
{/* First time setup hint */}
<div className="mt-6">
<div className="rounded-md bg-blue-50 dark:bg-blue-900/20 p-4">
<div className="flex">
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-300">
First Time?
</h3>
<div className="mt-2 text-sm text-blue-700 dark:text-blue-200">
<p>
If this is a fresh installation, you need to complete the initial setup.
</p>
<a
href="/"
className="mt-2 inline-flex items-center font-medium underline hover:text-blue-600"
>
Go to Setup Page
<ExternalLink className="ml-1 h-3 w-3" />
</a>
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
(Check server logs for the setup token)
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { api } from '../api/client';
interface User {
id: string;
email: string;
name: string | null;
role: string;
organization_id: string | null;
}
interface AuthState {
// State
token: string | null;
user: User | null;
isLoading: boolean;
error: string | null;
// Actions
login: (email: string, password: string) => Promise<boolean>;
logout: () => void;
clearError: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
// Initial state
token: null,
user: null,
isLoading: false,
error: null,
// Login action - returns true if successful, false otherwise
login: async (email: string, password: string): Promise<boolean> => {
set({ isLoading: true, error: null });
try {
const { data, error: apiError } = await api.login({
email,
password
});
if (apiError) {
const errorData = apiError as { error?: string };
set({
isLoading: false,
error: errorData?.error || 'Login failed. Please check your credentials.'
});
return false;
}
if (!data) {
set({ isLoading: false, error: 'No response from server' });
return false;
}
// Store token and user info
const { token, user } = data as { token: string; user: User };
localStorage.setItem('token', token);
set({
token,
user,
isLoading: false,
error: null
});
return true;
} catch (err) {
set({
isLoading: false,
error: 'An unexpected error occurred. Please try again.'
});
console.error('Login error:', err);
return false;
}
},
// Logout action
logout: () => {
localStorage.removeItem('token');
set({
token: null,
user: null,
error: null
});
},
// Clear error
clearError: () => {
set({ error: null });
}
}),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token }),
}
)
);

View File

@@ -1,10 +1,67 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
import http from 'http'
// Plugin to check setup status and serve setup page from backend if needed
const setupCheckPlugin = () => ({
name: 'setup-check',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
// Only check root paths
if (req.url !== '/' && req.url !== '/index.html') {
return next()
}
try {
// Check setup status from backend
const setupStatus = await new Promise<{ setup_required: boolean }>((resolve, reject) => {
const request = http.get('http://localhost:8080/api/v1/auth/setup-status', (response) => {
let data = ''
response.on('data', chunk => data += chunk)
response.on('end', () => {
try {
resolve(JSON.parse(data))
} catch (e) {
reject(e)
}
})
})
request.on('error', reject)
request.setTimeout(3000, () => reject(new Error('Timeout')))
})
// If setup required, proxy the setup page from backend
if (setupStatus.setup_required) {
// Fetch setup page from backend
const setupPage = await new Promise<string>((resolve, reject) => {
const request = http.get('http://localhost:8080/', (response) => {
let data = ''
response.on('data', chunk => data += chunk)
response.on('end', () => resolve(data))
})
request.on('error', reject)
request.setTimeout(5000, () => reject(new Error('Timeout')))
})
// Serve the setup page
res.setHeader('Content-Type', 'text/html')
res.end(setupPage)
return
}
} catch (err) {
// If backend is not running or error occurs, continue to app
console.warn('[setup-check] Could not check setup status, assuming setup complete')
}
next()
})
},
})
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), setupCheckPlugin()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),