feat: JWT authentication with RBAC and default admin

Auth: JWT token-based authentication with argon2 password hashing.
Auto-generated secret persisted in SQLite settings table. Middleware
validates Bearer tokens on all /api/* endpoints except /api/auth/login.

RBAC: admin (all operations) and viewer (GET only). Default admin
user created on first run (password: "admin", logged as warning).

Endpoints: POST /api/auth/login, POST /api/auth/register (admin only),
GET /api/auth/me. WebSocket endpoints validate ?token= query parameter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaLaw2 2026-03-21 16:04:03 +08:00
parent 9247d42ff6
commit 2e9dbe514f
15 changed files with 439 additions and 4 deletions

View File

@ -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 }

View File

@ -1,5 +1,6 @@
[Http]
http_server_bind_port = 8080
jwt_expiry_hours = 24
[Network]
ingress_ifname = "ng-ext"

View File

@ -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 }

View File

@ -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<Self, Error> {
let secret = match db.get_setting("jwt_secret")? {
Some(s) => s,
None => {
use rand::Rng;
let secret: Vec<u8> = 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<String, Error> {
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<Claims, Error> {
let token_data = decode::<Claims>(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
}

View File

@ -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<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Transform = AuthMiddlewareService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(AuthMiddlewareService {
service: Rc::new(service),
}))
}
}
pub struct AuthMiddlewareService<S> {
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(
&self,
ctx: &mut core::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
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::<web::Data<JwtService>>() {
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)
})
}
}

View File

@ -0,0 +1,3 @@
pub mod jwt;
pub mod middleware;
pub mod password;

View File

@ -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<String, Error> {
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<bool, Error> {
let parsed = PasswordHash::new(hash).map_err(|_| AuthError::InvalidCredentials)?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}

View File

@ -1,3 +1,4 @@
pub mod auth;
pub mod database;
pub mod ebpf;
pub mod infrastructure;

View File

@ -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<EbpfServices>,
pub app_services: Arc<MLService>,
pub db: Arc<Database>,
pub jwt_service: Arc<JwtService>,
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())

View File

@ -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,

View File

@ -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,
}
}

View File

@ -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<AuthError> for Error {
fn from(error: AuthError) -> Self {
Self::Auth(error)
}
}
impl From<DatabaseError> for Error {
fn from(error: DatabaseError) -> Self {
Self::Database(error)

View File

@ -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<LoginRequest>,
db: web::Data<Database>,
jwt: web::Data<JwtService>,
) -> 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<RegisterRequest>,
db: web::Data<Database>,
) -> impl Responder {
// Check caller is admin
let claims = req.extensions().get::<Claims>().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(&reg.password) {
Ok(h) => h,
Err(_) => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Failed to hash password"}))
}
};
match db.insert_user(&reg.username, &hash, &reg.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::<Claims>().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"})),
}
}

View File

@ -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;

View File

@ -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<String>,
}
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<WsQuery>, jwt: &web::Data<JwtService>) -> 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<SystemHealth>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> 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<MLAlert>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> 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<FlowStatistics>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> 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<DropMonitor>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> 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)})),