Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/run-all-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ on:
description: '📂 docker_tests/ (Environment & PUID/PGID)'
type: boolean
default: false
run_ldap:
description: '🔑 LDAP (Auth plugins, unit and UI tests)'
type: boolean
default: true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
run_ui:
description: '📂 ui/ (Selenium & Dashboard)'
type: boolean
Expand Down Expand Up @@ -60,6 +64,7 @@ jobs:
INPUT_RUN_API: ${{ github.event.inputs.run_api }}
INPUT_RUN_BACKEND: ${{ github.event.inputs.run_backend }}
INPUT_RUN_DOCKER_ENV: ${{ github.event.inputs.run_docker_env }}
INPUT_RUN_LDAP: ${{ github.event.inputs.run_ldap }}
INPUT_RUN_UI: ${{ github.event.inputs.run_ui }}
INPUT_RUN_PLUGINS: ${{ github.event.inputs.run_plugins }}
INPUT_RUN_ROOT_FILES: ${{ github.event.inputs.run_root_files }}
Expand All @@ -85,6 +90,7 @@ jobs:
if [ "$INPUT_RUN_API" == "true" ]; then PATHS="$PATHS test/api_endpoints/ test/server/"; fi
if [ "$INPUT_RUN_BACKEND" == "true" ]; then PATHS="$PATHS test/backend/ test/db/"; fi
if [ "$INPUT_RUN_DOCKER_ENV" == "true" ]; then PATHS="$PATHS test/docker_tests/"; fi
if [ "$INPUT_RUN_LDAP" == "true" ]; then PATHS="$PATHS test/server/test_ldap_provider.py test/server/test_ldap_provider_env.py test/ui/test_ui_ldap_login.py test/docker_tests/test_ldap_ui.py"; fi
if [ "$INPUT_RUN_UI" == "true" ]; then PATHS="$PATHS test/ui/"; fi
if [ "$INPUT_RUN_PLUGINS" == "true" ]; then PATHS="$PATHS test/plugins/"; fi

Expand Down
154 changes: 144 additions & 10 deletions front/index.php
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<!-- NetAlertX CSS -->
<link rel="stylesheet" href="css/app.css">
Comment thread
adamoutler marked this conversation as resolved.

<?php

require_once $_SERVER['DOCUMENT_ROOT'].'/php/server/db.php';
require_once $_SERVER['DOCUMENT_ROOT'].'/php/templates/language/lang.php';
require_once $_SERVER['DOCUMENT_ROOT'].'/php/templates/security.php';

if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// if (session_status() === PHP_SESSION_NONE) {
// session_start();
// }
Expand All @@ -15,6 +16,57 @@

const DEFAULT_REDIRECT = '/devices.php';

/* =====================================================
LDAP Configuration
$configLines and $api_token are already loaded by security.php
===================================================== */

// Config file is the primary source of truth; fall back to SQLite if not yet persisted to file
$ldap_enabled = strtolower(trim(getConfigLine('/^LDAP_enabled\s*=/', $configLines)[1] ?? 'false')) === 'true';
if (!$ldap_enabled) {
try {
$db = SQLite3_connect();
if ($db) {
$db_ldap = $db->querySingle("SELECT setValue FROM Settings WHERE setKey = 'LDAP_enabled'");
if (is_string($db_ldap) && strtolower(trim($db_ldap)) === 'true') {
$ldap_enabled = true;
}
}
} catch (\Throwable $e) {
// Ignore connection errors if database is uninitialized
}
}

// Fall back to SQLite for API_TOKEN if not present in app.conf
if (empty($api_token)) {
try {
$db = SQLite3_connect();
if ($db) {
$db_token = $db->querySingle("SELECT setValue FROM Settings WHERE setKey = 'API_TOKEN'");
if (!empty($db_token) && is_string($db_token)) {
$api_token = $db_token;
}
}
} catch (\Throwable $e) {
// Ignore connection errors if database is uninitialized
}
}

/**
* Derive the Python API port from the GRAPHQL_PORT setting in app.conf.
* Falls back to 20212 (the default) when not set.
*/
$gql_line = getConfigLine('/^GRAPHQL_PORT.*=/', $configLines);
$graphql_port = 20212;
if ($gql_line !== null && isset($gql_line[1])) {
$parsed_port = (int) preg_replace('/[^0-9]/', '', $gql_line[1]);
if ($parsed_port >= 1 && $parsed_port <= 65535) {
$graphql_port = $parsed_port;
}
}

$ldap_login_url = "http://127.0.0.1:{$graphql_port}/api/auth/login";
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/* =====================================================
Helper Functions
===================================================== */
Expand Down Expand Up @@ -83,8 +135,24 @@ function is_authenticated(): bool {
}

function login_user(): void {
global $nax_Password, $api_token, $configLines;

$_SESSION['login'] = 1;
session_regenerate_id(true);
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

// Set remember-me cookie with HMAC when API_TOKEN is available.
// On first boot the token may not exist yet - skip the cookie gracefully.
if (!empty($api_token)) {
$cookie_value = hash_hmac('sha256', $nax_Password, $api_token);
setcookie(COOKIE_SAVE_LOGIN_NAME, $cookie_value, [
'expires' => time() + 3600 * 24 * 7,
'path' => '/',
'httponly' => true,
'secure' => !empty($_SERVER['HTTPS']),
'samesite' => 'Strict',
]);
}
}


Expand Down Expand Up @@ -114,16 +182,68 @@ function logout_user(): void {
Login Attempt
===================================================== */

if (!empty($_POST['loginpassword'])) {

$incomingHash = hash('sha256', $_POST['loginpassword']);
if (!empty($_POST['loginpassword']) &&
isset($_POST['csrf_token']) &&
hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'])) {

if ($ldap_enabled) {
// Resolve API token if empty (may be in SQLite rather than app.conf)
if (empty($api_token)) {
try {
$db = SQLite3_connect();
if ($db) {
$db_token = $db->querySingle("SELECT setValue FROM Settings WHERE setKey = 'API_TOKEN'");
if (!empty($db_token) && is_string($db_token)) {
$api_token = $db_token;
}
}
} catch (\Throwable $e) {
// Ignore connection errors if database is locked or uninitialized
}
}

if (hash_equals($nax_Password, $incomingHash)) {
// LDAP path: delegate credential validation to the Python API.
// The API token is required so only server-side callers can reach the endpoint.
if (empty($api_token)) {
error_log('[LDAP] Authentication error: API_TOKEN is not configured');
} else {
$ldap_payload = json_encode([
'username' => isset($_POST['loginusername']) ? trim($_POST['loginusername']) : '',
'password' => $_POST['loginpassword'],
]);
$stream_opts = [
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n"
. "Authorization: Bearer " . $api_token . "\r\n"
. "X-Forwarded-For: " . ($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1') . "\r\n",
'content' => $ldap_payload,
'timeout' => 5,
'ignore_errors' => true,
]
];
$ctx = stream_context_create($stream_opts);
$raw = @file_get_contents($ldap_login_url, false, $ctx);
$api_resp = ($raw !== false) ? @json_decode($raw, true) : null;

if (is_array($api_resp) && ($api_resp['success'] ?? false) === true) {
login_user();
safe_redirect(append_hash($redirectTo));
} else {
error_log('[LDAP] Auth failed from API: ' . ($raw !== false ? $raw : 'connection failed to ' . $ldap_login_url));
}
}
// Fall through to show the login form with an error state.
} else {
// Local path: compare SHA-256 digest against the stored hash (same as before).
$incomingHash = hash('sha256', $_POST['loginpassword']);

login_user();
if (hash_equals($nax_Password, $incomingHash)) {
login_user();

// Redirect to target page, preserving deep link hash if present
safe_redirect(append_hash($redirectTo));
// Redirect to target page, preserving deep link hash if present
safe_redirect(append_hash($redirectTo));
}
}
}

Expand Down Expand Up @@ -179,6 +299,9 @@ function logout_user(): void {
<!-- Favicon -->
<link id="favicon" rel="icon" type="image/x-icon" href="img/NetAlertX_logo.png">
<link rel="stylesheet" href="/css/offline-font.css">

<!-- NetAlertX CSS -->
<link rel="stylesheet" href="css/app.css">
</head>
<body class="hold-transition login-page col-sm-12 col-sx-12">
<div class="login-box login-custom">
Expand All @@ -193,8 +316,19 @@ function logout_user(): void {
? '?next=' . htmlspecialchars($_GET['next'], ENT_QUOTES, 'UTF-8')
: '';
?>" method="post">
<?php if ($ldap_enabled): ?>
<div class="form-group has-feedback">
<input type="text" class="form-control"
placeholder="<?= lang('Login_Username');?>"
name="loginusername"
autocomplete="username"
required>
<span class="glyphicon glyphicon-user form-control-feedback"></span>
</div>
<?php endif; ?>
<div class="form-group has-feedback">
<input type="hidden" name="url_hash" id="url_hash">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
<input type="password" class="form-control" placeholder="<?= lang('Login_Psw-box');?>" name="loginpassword">
<span class="glyphicon glyphicon-lock form-control-feedback"></span>
</div>
Expand Down
3 changes: 3 additions & 0 deletions front/php/server/query_graphql.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
// Helper function to get GraphQL URL (you can replace this with environment variables)
function getGraphQLUrl() {
$port = getSettingValue("GRAPHQL_PORT"); // Port for the GraphQL server
if (empty($port) || !is_numeric($port)) {
$port = 20211;
}
return "0.0.0.0:$port/graphql"; // Full URL to the GraphQL endpoint
}

Expand Down
24 changes: 21 additions & 3 deletions front/php/templates/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,29 @@
}

$config_file_lines = file($config_file);
$config_file_lines = array_values(preg_grep('/^SETPWD_password.*=/', $config_file_lines));
$password_line = explode("'", $config_file_lines[0]);
$config_file_lines_pw = array_values(preg_grep('/^SETPWD_password\s*=/', $config_file_lines ?: []));
if (empty($config_file_lines_pw) || substr_count($config_file_lines_pw[0], "'") < 2) {
http_response_code(401);
echo 'Unauthorized 401';
exit;
}
$password_line = explode("'", $config_file_lines_pw[0], 3);
$nax_Password = $password_line[1];

if (isset($_COOKIE[$CookieSaveLoginName]) && $nax_Password === $_COOKIE[$CookieSaveLoginName]) {
$config_file_lines_token = array_values(preg_grep('/^API_TOKEN\s*=/', $config_file_lines ?: []));
$token_line = !empty($config_file_lines_token) ? explode("'", $config_file_lines_token[0], 3) : [];
$api_token = $token_line[1] ?? '';
if (empty($api_token)) {
$api_token = getenv('API_TOKEN') ?: '';
Comment thread
adamoutler marked this conversation as resolved.
}
if ($api_token === '') {
http_response_code(401);
echo 'Unauthorized 401';
exit;
}

$expected_cookie = hash_hmac('sha256', $nax_Password, $api_token);
if (isset($_COOKIE[$CookieSaveLoginName]) && hash_equals($expected_cookie, $_COOKIE[$CookieSaveLoginName])) {
$isAuthenticated = true;
}

Expand Down
1 change: 1 addition & 0 deletions front/php/templates/language/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@
"Login_Remember": "Remember",
"Login_Remember_small": "(valid for 7 days)",
"Login_Submit": "Log in",
"Login_Username": "Username",
"Login_Toggle_Alert_headline": "Password Alert!",
"Login_Toggle_Info": "Password Information",
"Login_Toggle_Info_headline": "Password Information",
Expand Down
18 changes: 16 additions & 2 deletions front/php/templates/security.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,17 @@ function redirect($url) {
// Handle logout
if (!empty($_REQUEST['action']) && $_REQUEST['action'] == 'logout') {
session_destroy();
setcookie(COOKIE_SAVE_LOGIN_NAME, "", time() - 3600);

// Determine protocol for secure cookie flag
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://';

setcookie(COOKIE_SAVE_LOGIN_NAME, "", [
'expires' => time() - 3600,
'path' => '/',
'httponly' => true,
'secure' => $protocol === 'https://',
'samesite' => 'Strict',
]);
redirect('index.php');
}

Expand All @@ -102,14 +112,18 @@ function redirect($url) {

// Handle web protection and password
$nax_WebProtection = strtolower(trim(getConfigLine('/^SETPWD_enable_password.*=/', $configLines)[1] ?? 'false'));

// Auth plugins force web protection when enabled
if (strtolower(trim(getConfigLine('/^LDAP_enabled\s*=/', $configLines)[1] ?? 'false')) === 'true') $nax_WebProtection = 'true';

$nax_Password = getConfigValue('/^SETPWD_password.*=/', $configLines);
$api_token = getConfigValue('/^API_TOKEN.*=/', $configLines, "'");

$expectedToken = 'Bearer ' . $api_token;

// Authentication Handling
if ($nax_WebProtection == 'true') {
if ($authHeader === $expectedToken) {
if (!empty($api_token) && $authHeader === $expectedToken) {
$_SESSION['login'] = 1; // User authenticated with bearer token
} elseif (!empty($authHeader)) {
echo "[Security] Incorrect Bearer Token";
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ freebox-api
mcp
psutil
pydantic>=2.0,<3.0
ldap3>=2.9
fritzconnection>=1.15.1
4 changes: 2 additions & 2 deletions scripts/run_tests_in_docker_environment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ docker run -d --name netalertx-test-container \

# --- 5. Install Python test dependencies ---
echo "--- Installing Python test dependencies into venv ---"
docker exec netalertx-test-container pip3 install --break-system-packages pytest docker debugpy selenium
docker exec netalertx-test-container pip3 install --break-system-packages pytest docker debugpy selenium ldap3

# --- 6. Execute Setup Script ---
echo "--- Executing setup script inside the container ---"
Expand Down Expand Up @@ -96,7 +96,7 @@ docker exec netalertx-test-container /bin/bash -c " \
# --- 9. Execute Tests ---
echo "--- Executing tests inside the container ---"
docker exec netalertx-test-container /bin/bash -c " \
cd /workspaces/NetAlertX && pytest -m 'not (docker or compose or feature_complete)' --cache-clear -o cache_dir=/tmp/.pytest_cache; \
cd /workspaces/NetAlertX && pytest test/ -m 'not (docker or compose or feature_complete)' --cache-clear -o cache_dir=/tmp/.pytest_cache; \
"

# --- 10. Final Teardown ---
Expand Down
Loading
Loading