diff --git a/Cargo.toml b/Cargo.toml index fc27805..1558059 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,9 @@ maxminddb = "0.27.3" ipnetwork = "0.21.1" lru = "0.16.3" rusqlite = { version = "0.34", features = ["bundled"] } +jsonwebtoken = "9" +argon2 = "0.5" +rand = "0.9" # Build dependencies cargo_metadata = { version = "0.23.1", default-features = false } diff --git a/config.toml b/config.toml index 7707ab2..7a30f5c 100644 --- a/config.toml +++ b/config.toml @@ -1,5 +1,6 @@ [Http] http_server_bind_port = 8080 +jwt_expiry_hours = 24 [Network] ingress_ifname = "ng-ext" diff --git a/net-guardia/Cargo.toml b/net-guardia/Cargo.toml index 64acee4..272d99e 100644 --- a/net-guardia/Cargo.toml +++ b/net-guardia/Cargo.toml @@ -51,6 +51,9 @@ maxminddb = { workspace = true } ipnetwork = { workspace = true } lru = { workspace = true } rusqlite = { workspace = true } +jsonwebtoken = { workspace = true } +argon2 = { workspace = true } +rand = { workspace = true } [build-dependencies] cargo_metadata = { workspace = true } diff --git a/net-guardia/src/core/auth/jwt.rs b/net-guardia/src/core/auth/jwt.rs new file mode 100644 index 0000000..9c44f6d --- /dev/null +++ b/net-guardia/src/core/auth/jwt.rs @@ -0,0 +1,80 @@ +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +use crate::core::database::Database; +use crate::model::error::auth::AuthError; +use crate::model::error::Error; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: i64, + pub username: String, + pub role: String, + pub exp: usize, +} + +pub struct JwtService { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + expiry_hours: u64, +} + +impl JwtService { + pub fn new(db: &Database, expiry_hours: u64) -> Result { + let secret = match db.get_setting("jwt_secret")? { + Some(s) => s, + None => { + use rand::Rng; + let secret: Vec = rand::rng().random::<[u8; 32]>().to_vec(); + let encoded = hex_encode(&secret); + db.set_setting("jwt_secret", &encoded)?; + encoded + } + }; + + let secret_bytes = secret.as_bytes(); + Ok(Self { + encoding_key: EncodingKey::from_secret(secret_bytes), + decoding_key: DecodingKey::from_secret(secret_bytes), + expiry_hours, + }) + } + + pub fn create_token(&self, user_id: i64, username: &str, role: &str) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = Claims { + sub: user_id, + username: username.to_string(), + role: role.to_string(), + exp: (now + self.expiry_hours * 3600) as usize, + }; + + encode(&Header::default(), &claims, &self.encoding_key) + .map_err(|_| AuthError::InvalidToken.into()) + } + + pub fn validate_token(&self, token: &str) -> Result { + let token_data = decode::(token, &self.decoding_key, &Validation::default()) + .map_err(|e| { + if e.to_string().contains("ExpiredSignature") { + Error::from(AuthError::TokenExpired) + } else { + Error::from(AuthError::InvalidToken) + } + })?; + Ok(token_data.claims) + } +} + +fn hex_encode(data: &[u8]) -> String { + use std::fmt::Write; + let mut s = String::with_capacity(data.len() * 2); + for b in data { + write!(s, "{:02x}", b).unwrap(); + } + s +} diff --git a/net-guardia/src/core/auth/middleware.rs b/net-guardia/src/core/auth/middleware.rs new file mode 100644 index 0000000..5367a7a --- /dev/null +++ b/net-guardia/src/core/auth/middleware.rs @@ -0,0 +1,117 @@ +use std::future::{ready, Future, Ready}; +use std::pin::Pin; +use std::rc::Rc; + +use actix_web::body::EitherBody; +use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; +use actix_web::{web, Error as ActixError, HttpMessage, HttpResponse}; + +use crate::core::auth::jwt::JwtService; + +pub struct AuthMiddleware; + +impl Transform for AuthMiddleware +where + S: Service, Error = ActixError> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = ActixError; + type Transform = AuthMiddlewareService; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(AuthMiddlewareService { + service: Rc::new(service), + })) + } +} + +pub struct AuthMiddlewareService { + service: Rc, +} + +impl Service for AuthMiddlewareService +where + S: Service, Error = ActixError> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = ActixError; + type Future = Pin>>>; + + fn poll_ready( + &self, + ctx: &mut core::task::Context<'_>, + ) -> std::task::Poll> { + self.service.poll_ready(ctx) + } + + fn call(&self, req: ServiceRequest) -> Self::Future { + let service = Rc::clone(&self.service); + + Box::pin(async move { + let path = req.path().to_string(); + + // Skip auth for login endpoint and non-API routes + if path == "/api/auth/login" || !path.starts_with("/api/") { + let res = service.call(req).await?.map_into_left_body(); + return Ok(res); + } + + // Extract JWT service from app data + let jwt_service = match req.app_data::>() { + Some(s) => s.clone(), + None => { + let resp = HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Auth not configured"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + }; + + // Extract token from Authorization header + let auth_header = req.headers().get("Authorization"); + let token = match auth_header { + Some(val) => { + let val_str = val.to_str().unwrap_or(""); + if val_str.starts_with("Bearer ") { + &val_str[7..] + } else { + let resp = HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Invalid authorization header"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + } + None => { + let resp = HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Missing authorization header"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + }; + + // Validate token + let claims = match jwt_service.validate_token(token) { + Ok(c) => c, + Err(_) => { + let resp = HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Invalid or expired token"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + }; + + // RBAC: viewer can only GET + if claims.role == "viewer" && req.method() != actix_web::http::Method::GET { + let resp = HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Insufficient permissions"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + + // Store claims in request extensions + req.extensions_mut().insert(claims); + + let res = service.call(req).await?.map_into_left_body(); + Ok(res) + }) + } +} diff --git a/net-guardia/src/core/auth/mod.rs b/net-guardia/src/core/auth/mod.rs new file mode 100644 index 0000000..d694405 --- /dev/null +++ b/net-guardia/src/core/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod jwt; +pub mod middleware; +pub mod password; diff --git a/net-guardia/src/core/auth/password.rs b/net-guardia/src/core/auth/password.rs new file mode 100644 index 0000000..9190110 --- /dev/null +++ b/net-guardia/src/core/auth/password.rs @@ -0,0 +1,22 @@ +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use rand::rngs::OsRng; + +use crate::model::error::auth::AuthError; +use crate::model::error::Error; + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|_| AuthError::InvalidCredentials)?; + Ok(hash.to_string()) +} + +pub fn verify_password(password: &str, hash: &str) -> Result { + let parsed = PasswordHash::new(hash).map_err(|_| AuthError::InvalidCredentials)?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok()) +} diff --git a/net-guardia/src/core/mod.rs b/net-guardia/src/core/mod.rs index fbf4a92..b378b07 100644 --- a/net-guardia/src/core/mod.rs +++ b/net-guardia/src/core/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod database; pub mod ebpf; pub mod infrastructure; diff --git a/net-guardia/src/core/system.rs b/net-guardia/src/core/system.rs index 5eb8942..ad4ac70 100644 --- a/net-guardia/src/core/system.rs +++ b/net-guardia/src/core/system.rs @@ -10,6 +10,8 @@ use aya_log::EbpfLogger; use common::define::pipeline::*; use macros::log; +use crate::core::auth::jwt::JwtService; +use crate::core::auth::password; use crate::core::database::Database; use crate::core::ebpf::EbpfServices; use crate::core::infrastructure::app_config::AppConfig; @@ -22,7 +24,7 @@ use crate::model::error::Error; use crate::model::log::ml::MLLog; use crate::model::log::system::SystemLog; use crate::utils::logging::Logging; -use crate::web::api::{acl, filter, rate_limit as rate_limit_api, stats, health as health_api, ml, system as system_api, default, ws}; +use crate::web::api::{acl, auth, filter, rate_limit as rate_limit_api, stats, health as health_api, ml, system as system_api, default, ws}; /// Maps stage name (from config.toml) to (function_name, stage_id) fn stage_registry() -> HashMap<&'static str, (&'static str, u32)> { @@ -39,6 +41,7 @@ pub struct System { pub ebpf_services: Arc, pub app_services: Arc, pub db: Arc, + pub jwt_service: Arc, pub ingress_ebpf: Ebpf, pub egress_ebpf: Ebpf, #[allow(dead_code)] @@ -65,6 +68,15 @@ impl System { let db = Arc::new(Database::new(&app_config.misc.database_path)?); + // Create default admin user if no users exist + if db.user_count().unwrap_or(0) == 0 { + let hash = password::hash_password("admin")?; + db.insert_user("admin", &hash, "admin")?; + tracing::warn!("Default admin user created with password 'admin' — change it immediately"); + } + + let jwt_service = Arc::new(JwtService::new(&db, app_config.http.jwt_expiry_hours)?); + let ebpf_services = Arc::new(EbpfServices::new( app_config.clone(), &mut ingress_ebpf, @@ -122,6 +134,7 @@ impl System { ebpf_services, app_services, db, + jwt_service, ingress_ebpf, egress_ebpf, ingress_program_array, @@ -211,6 +224,7 @@ impl System { let flow_statistics = self.app_services.flow_statistics.clone(); let drop_monitor = self.ebpf_services.drop_monitor.clone(); let db = self.db.clone(); + let jwt_service = self.jwt_service.clone(); let port = self.app_config.http.http_server_bind_port; HttpServer::new(move || { let cors = actix_cors::Cors::default() @@ -234,8 +248,11 @@ impl System { .app_data(web::Data::from(flow_statistics.clone())) .app_data(web::Data::from(drop_monitor.clone())) .app_data(web::Data::from(db.clone())) + .app_data(web::Data::from(jwt_service.clone())) .service( web::scope("/api") + .wrap(crate::core::auth::middleware::AuthMiddleware) + .service(auth::initialize()) .service(acl::initialize()) .service(filter::initialize()) .service(rate_limit_api::initialize()) diff --git a/net-guardia/src/model/config.rs b/net-guardia/src/model/config.rs index 14c98b2..291df91 100644 --- a/net-guardia/src/model/config.rs +++ b/net-guardia/src/model/config.rs @@ -17,8 +17,12 @@ pub struct AppConfigTable { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct HttpConfig { pub http_server_bind_port: u16, + #[serde(default = "default_jwt_expiry")] + pub jwt_expiry_hours: u64, } +fn default_jwt_expiry() -> u64 { 24 } + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct NetworkConfig { pub ingress_ifname: String, diff --git a/net-guardia/src/model/error/auth.rs b/net-guardia/src/model/error/auth.rs new file mode 100644 index 0000000..c28bd9e --- /dev/null +++ b/net-guardia/src/model/error/auth.rs @@ -0,0 +1,20 @@ +use macros::traceable; + +traceable! { + AuthError { + #[error("Invalid credentials")] + InvalidCredentials => tracing::Level::WARN, + + #[error("Token expired")] + TokenExpired => tracing::Level::WARN, + + #[error("Invalid token")] + InvalidToken => tracing::Level::WARN, + + #[error("Insufficient permissions")] + InsufficientPermissions => tracing::Level::WARN, + + #[error("Missing authorization header")] + MissingAuthHeader => tracing::Level::WARN, + } +} diff --git a/net-guardia/src/model/error/mod.rs b/net-guardia/src/model/error/mod.rs index f16d3a0..0ce0081 100644 --- a/net-guardia/src/model/error/mod.rs +++ b/net-guardia/src/model/error/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod database; pub mod ebpf; pub mod http; @@ -8,6 +9,7 @@ pub mod system; use serde::{Deserialize, Serialize}; +use crate::model::error::auth::AuthError; use crate::model::error::database::DatabaseError; use crate::model::error::ebpf::EbpfError; use crate::model::error::http::HttpError; @@ -18,6 +20,8 @@ use crate::model::error::system::SystemError; #[derive(Clone, Debug, thiserror::Error, Serialize, Deserialize)] pub enum Error { + #[error("{0}")] + Auth(AuthError), #[error("{0}")] Database(DatabaseError), #[error("{0}")] @@ -34,6 +38,12 @@ pub enum Error { System(SystemError), } +impl From for Error { + fn from(error: AuthError) -> Self { + Self::Auth(error) + } +} + impl From for Error { fn from(error: DatabaseError) -> Self { Self::Database(error) diff --git a/net-guardia/src/web/api/auth.rs b/net-guardia/src/web/api/auth.rs new file mode 100644 index 0000000..fc58871 --- /dev/null +++ b/net-guardia/src/web/api/auth.rs @@ -0,0 +1,113 @@ +use actix_web::{web, HttpRequest, HttpResponse, Responder, Scope}; +use serde::Deserialize; + +use crate::core::auth::jwt::{Claims, JwtService}; +use crate::core::auth::password; +use crate::core::database::Database; + +#[derive(Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +#[derive(Deserialize)] +struct RegisterRequest { + username: String, + password: String, + role: String, +} + +pub fn initialize() -> Scope { + web::scope("/auth") + .route("/login", web::post().to(login)) + .route("/register", web::post().to(register)) + .route("/me", web::get().to(me)) +} + +async fn login( + body: web::Json, + db: web::Data, + jwt: web::Data, +) -> impl Responder { + let req = body.into_inner(); + + let user = match db.find_user(&req.username) { + Ok(Some(u)) => u, + _ => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Invalid credentials"})) + } + }; + + let (id, username, hash, role) = user; + + match password::verify_password(&req.password, &hash) { + Ok(true) => {} + _ => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Invalid credentials"})) + } + } + + match jwt.create_token(id, &username, &role) { + Ok(token) => HttpResponse::Ok().json(serde_json::json!({ + "token": token, + "role": role, + })), + Err(_) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Failed to create token"})), + } +} + +async fn register( + req: HttpRequest, + body: web::Json, + db: web::Data, +) -> impl Responder { + // Check caller is admin + let claims = req.extensions().get::().cloned(); + match claims { + Some(c) if c.role == "admin" => {} + _ => { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})) + } + } + + let reg = body.into_inner(); + + // Validate role + if reg.role != "admin" && reg.role != "viewer" { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"})); + } + + let hash = match password::hash_password(®.password) { + Ok(h) => h, + Err(_) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Failed to hash password"})) + } + }; + + match db.insert_user(®.username, &hash, ®.role) { + Ok(_) => HttpResponse::Created() + .json(serde_json::json!({"username": reg.username, "role": reg.role})), + Err(e) => { + HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})) + } + } +} + +async fn me(req: HttpRequest) -> impl Responder { + match req.extensions().get::().cloned() { + Some(claims) => HttpResponse::Ok().json(serde_json::json!({ + "id": claims.sub, + "username": claims.username, + "role": claims.role, + })), + None => HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})), + } +} diff --git a/net-guardia/src/web/api/mod.rs b/net-guardia/src/web/api/mod.rs index 90d4c4c..ac66ed7 100644 --- a/net-guardia/src/web/api/mod.rs +++ b/net-guardia/src/web/api/mod.rs @@ -1,9 +1,10 @@ pub mod acl; +pub mod auth; +pub mod default; pub mod filter; -pub mod rate_limit; -pub mod stats; pub mod health; pub mod ml; +pub mod rate_limit; +pub mod stats; pub mod system; -pub mod default; pub mod ws; diff --git a/net-guardia/src/web/api/ws.rs b/net-guardia/src/web/api/ws.rs index e624dd5..8e38114 100644 --- a/net-guardia/src/web/api/ws.rs +++ b/net-guardia/src/web/api/ws.rs @@ -1,11 +1,18 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder, Scope}; +use serde::Deserialize; +use crate::core::auth::jwt::JwtService; use crate::core::ebpf::drop_monitor::DropMonitor; use crate::core::infrastructure::health::SystemHealth; use crate::core::infrastructure::statistics::FlowStatistics; use crate::core::ml::alert::MLAlert; use crate::web::websocket::{alert_websocket, drop_websocket, flow_websocket, health_websocket}; +#[derive(Deserialize)] +struct WsQuery { + token: Option, +} + pub fn initialize() -> Scope { web::scope("/ws") .route("/health", web::get().to(health_ws)) @@ -14,11 +21,29 @@ pub fn initialize() -> Scope { .route("/drops", web::get().to(drops_ws)) } +fn validate_ws_token(query: &web::Query, jwt: &web::Data) -> Result<(), HttpResponse> { + match &query.token { + Some(token) => { + jwt.validate_token(token) + .map(|_| ()) + .map_err(|_| HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Invalid or expired token"}))) + } + None => Err(HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Missing token query parameter"}))), + } +} + async fn health_ws( req: HttpRequest, stream: web::Payload, health: web::Data, + query: web::Query, + jwt: web::Data, ) -> impl Responder { + if let Err(resp) = validate_ws_token(&query, &jwt) { + return resp; + } match health_websocket::websocket_system_health(req, stream, health).await { Ok(response) => response, Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})), @@ -29,7 +54,12 @@ async fn alerts_ws( req: HttpRequest, stream: web::Payload, ai: web::Data, + query: web::Query, + jwt: web::Data, ) -> impl Responder { + if let Err(resp) = validate_ws_token(&query, &jwt) { + return resp; + } match alert_websocket::websocket_alert(req, stream, ai).await { Ok(response) => response, Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})), @@ -40,7 +70,12 @@ async fn flows_ws( req: HttpRequest, stream: web::Payload, stats: web::Data, + query: web::Query, + jwt: web::Data, ) -> impl Responder { + if let Err(resp) = validate_ws_token(&query, &jwt) { + return resp; + } match flow_websocket::flow_stats_ws(req, stream, stats).await { Ok(response) => response, Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})), @@ -51,7 +86,12 @@ async fn drops_ws( req: HttpRequest, stream: web::Payload, monitor: web::Data, + query: web::Query, + jwt: web::Data, ) -> impl Responder { + if let Err(resp) = validate_ws_token(&query, &jwt) { + return resp; + } match drop_websocket::websocket_drops(req, stream, monitor).await { Ok(response) => response, Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})),