Merge pull request #1500 from netalertx/next_release

Next release - deep link support after log in and refactor of index.php
This commit is contained in:
Jokob @NetAlertX
2026-02-22 16:22:08 +11:00
committed by GitHub
7 changed files with 596 additions and 77 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 201 KiB

View File

@@ -3,76 +3,155 @@
<?php
//------------------------------------------------------------------------------
// check if authenticated
// Be CAREFUL WHEN INCLUDING NEW PHP FILES
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';
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';
$CookieSaveLoginName = 'NetAlertX_SaveLogin';
// if (session_status() === PHP_SESSION_NONE) {
// session_start();
// }
if ($nax_WebProtection != 'true')
{
header('Location: devices.php');
$_SESSION["login"] = 1;
session_start();
const DEFAULT_REDIRECT = '/devices.php';
/* =====================================================
Helper Functions
===================================================== */
function safe_redirect(string $path): void {
header("Location: {$path}", true, 302);
exit;
}
// Logout
if (isset ($_GET["action"]) && $_GET["action"] == 'logout')
{
setcookie($CookieSaveLoginName, '', time()+1); // reset cookie
$_SESSION["login"] = 0;
header('Location: index.php');
exit;
function validate_local_path(?string $encoded): string {
if (!$encoded) return DEFAULT_REDIRECT;
$decoded = base64_decode($encoded, true);
if ($decoded === false) {
return DEFAULT_REDIRECT;
}
// strict local path check (allow safe query strings + fragments)
// Using ~ as the delimiter instead of #
if (!preg_match('~^(?!//)(?!.*://)/[a-zA-Z0-9_\-./?=&:%#]*$~', $decoded)) {
return DEFAULT_REDIRECT;
}
return $decoded;
}
// Password without Cookie check -> pass and set initial cookie
if (isset ($_POST["loginpassword"]) && $nax_Password === hash('sha256',$_POST["loginpassword"]))
{
header('Location: devices.php');
$_SESSION["login"] = 1;
if (isset($_POST['PWRemember'])) {setcookie($CookieSaveLoginName, hash('sha256',$_POST["loginpassword"]), time()+604800);}
function extract_hash_from_path(string $path): array {
/*
Split a path into path and hash components.
For deep links encoded in the 'next' parameter like /devices.php#device-123,
extract the hash fragment so it can be properly included in the redirect.
Args:
path: Full path potentially with hash (e.g., "/devices.php#device-123")
Returns:
Array with keys 'path' (without hash) and 'hash' (with # prefix, or empty string)
*/
$parts = explode('#', $path, 2);
return [
'path' => $parts[0],
'hash' => !empty($parts[1]) ? '#' . $parts[1] : ''
];
}
// active Session or valid cookie (cookie not extends)
if (( isset ($_SESSION["login"]) && ($_SESSION["login"] == 1)) || (isset ($_COOKIE[$CookieSaveLoginName]) && $nax_Password === $_COOKIE[$CookieSaveLoginName]))
{
header('Location: devices.php');
$_SESSION["login"] = 1;
if (isset($_POST['PWRemember'])) {setcookie($CookieSaveLoginName, hash('sha256',$_POST["loginpassword"]), time()+604800);}
function append_hash(string $url): string {
// First check if the URL already has a hash from the deep link
$parts = extract_hash_from_path($url);
if (!empty($parts['hash'])) {
return $parts['path'] . $parts['hash'];
}
// Fall back to POST url_hash (for browser-captured hashes)
if (!empty($_POST['url_hash'])) {
$sanitized = preg_replace('/[^#a-zA-Z0-9_\-]/', '', $_POST['url_hash']);
if (str_starts_with($sanitized, '#')) {
return $url . $sanitized;
}
}
return $url;
}
function is_authenticated(): bool {
return isset($_SESSION['login']) && $_SESSION['login'] === 1;
}
function login_user(): void {
$_SESSION['login'] = 1;
session_regenerate_id(true);
}
function logout_user(): void {
$_SESSION = [];
session_destroy();
}
/* =====================================================
Redirect Handling
===================================================== */
$redirectTo = validate_local_path($_GET['next'] ?? null);
/* =====================================================
Web Protection Disabled
===================================================== */
if ($nax_WebProtection !== 'true') {
if (!is_authenticated()) {
login_user();
}
safe_redirect(append_hash($redirectTo));
}
/* =====================================================
Login Attempt
===================================================== */
if (!empty($_POST['loginpassword'])) {
$incomingHash = hash('sha256', $_POST['loginpassword']);
if (hash_equals($nax_Password, $incomingHash)) {
login_user();
// Redirect to target page, preserving deep link hash if present
safe_redirect(append_hash($redirectTo));
}
}
/* =====================================================
Already Logged In
===================================================== */
if (is_authenticated()) {
safe_redirect(append_hash($redirectTo));
}
/* =====================================================
Login UI Variables
===================================================== */
$login_headline = lang('Login_Toggle_Info_headline');
$login_info = lang('Login_Info');
$login_mode = 'danger';
$login_display_mode = 'display: block;';
$login_icon = 'fa-info';
$login_info = lang('Login_Info');
$login_mode = 'info';
$login_display_mode = 'display:none;';
$login_icon = 'fa-info';
// no active session, cookie not checked
if (isset ($_SESSION["login"]) == FALSE || $_SESSION["login"] != 1)
{
if ($nax_Password === '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92')
{
if ($nax_Password === '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92') {
$login_info = lang('Login_Default_PWD');
$login_mode = 'danger';
$login_display_mode = 'display: block;';
$login_display_mode = 'display:block;';
$login_headline = lang('Login_Toggle_Alert_headline');
$login_icon = 'fa-ban';
}
else
{
$login_mode = 'info';
$login_display_mode = 'display: none;';
$login_headline = lang('Login_Toggle_Info_headline');
$login_icon = 'fa-info';
}
}
// ##################################################
// ## Login Processing end
// ##################################################
?>
<!DOCTYPE html>
@@ -109,27 +188,21 @@ if (isset ($_SESSION["login"]) == FALSE || $_SESSION["login"] != 1)
<!-- /.login-logo -->
<div class="login-box-body">
<p class="login-box-msg"><?= lang('Login_Box');?></p>
<form action="index.php" method="post">
<form action="index.php<?php
echo !empty($_GET['next'])
? '?next=' . htmlspecialchars($_GET['next'], ENT_QUOTES, 'UTF-8')
: '';
?>" method="post">
<div class="form-group has-feedback">
<input type="hidden" name="url_hash" id="url_hash">
<input type="password" class="form-control" placeholder="<?= lang('Login_Psw-box');?>" name="loginpassword">
<span class="glyphicon glyphicon-lock form-control-feedback"></span>
</div>
<div class="row">
<div class="col-xs-8">
<div class="checkbox icheck">
<label>
<input type="checkbox" name="PWRemember">
<div style="margin-left: 10px; display: inline-block; vertical-align: top;">
<?= lang('Login_Remember');?><br><span style="font-size: smaller"><?= lang('Login_Remember_small');?></span>
</div>
</label>
</div>
</div>
<!-- /.col -->
<div class="col-xs-4" style="padding-top: 10px;">
<div class="col-xs-12">
<button type="submit" class="btn btn-primary btn-block btn-flat"><?= lang('Login_Submit');?></button>
</div>
<!-- /.col -->
<!-- /.col -->
</div>
</form>
@@ -159,6 +232,9 @@ if (isset ($_SESSION["login"]) == FALSE || $_SESSION["login"] != 1)
<!-- iCheck -->
<script src="lib/iCheck/icheck.min.js"></script>
<script>
if (window.location.hash) {
document.getElementById('url_hash').value = window.location.hash;
}
$(function () {
$('input').iCheck({
checkboxClass: 'icheckbox_square-blue',
@@ -174,7 +250,7 @@ function Passwordhinfo() {
} else {
x.style.display = "none";
}
}
}
</script>
</body>

View File

@@ -1,5 +1,10 @@
<?php
// Start session if not already started
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
// Constants
$configFolderPath = rtrim(getenv('NETALERTX_CONFIG') ?: '/data/config', '/');
$legacyConfigPath = $_SERVER['DOCUMENT_ROOT'] . "/../config/app.conf";
@@ -45,10 +50,6 @@ $isLogonPage = ($parsedUrl === '/' || $parsedUrl === '/index.php');
$authHeader = apache_request_headers()['Authorization'] ?? '';
$sessionLogin = isset($_SESSION['login']) ? $_SESSION['login'] : 0;
// Start session if not already started
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
// Handle logout
if (!empty($_REQUEST['action']) && $_REQUEST['action'] == 'logout') {
@@ -82,11 +83,12 @@ if ($nax_WebProtection == 'true') {
$isLoggedIn = isset($_SESSION['login']) && $_SESSION['login'] == 1;
// Determine if the user should be redirected
if ($isLoggedIn || $isLogonPage || (isset($_COOKIE[COOKIE_SAVE_LOGIN_NAME]) && $nax_Password === $_COOKIE[COOKIE_SAVE_LOGIN_NAME])) {
if ($isLoggedIn || $isLogonPage) {
// Logged in or stay on this page if we are on the index.php already
} else {
// We need to redirect
redirect('/index.php');
$returnUrl = rawurlencode(base64_encode($_SERVER['REQUEST_URI']));
redirect("/index.php?next=" . $returnUrl);
exit; // exit is needed to prevent authentication bypass
}
}

View File

@@ -75,7 +75,7 @@ from .openapi.schemas import ( # noqa: E402 [flake8 lint suppression]
BaseResponse, DeviceTotalsResponse,
DeviceTotalsNamedResponse,
EventsTotalsNamedResponse,
DeleteDevicesRequest, DeviceImportRequest,
DeleteDevicesRequest,
DeviceImportResponse, UpdateDeviceColumnRequest,
LockDeviceFieldRequest, UnlockDeviceFieldsRequest,
CopyDeviceRequest, TriggerScanRequest,
@@ -94,7 +94,7 @@ from .openapi.schemas import ( # noqa: E402 [flake8 lint suppression]
DbQueryRequest, DbQueryResponse,
DbQueryUpdateRequest, DbQueryDeleteRequest,
AddToQueueRequest, GetSettingResponse,
RecentEventsRequest, SetDeviceAliasRequest
RecentEventsRequest, SetDeviceAliasRequest,
)
from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression]
@@ -1933,6 +1933,9 @@ def check_auth(payload=None):
return jsonify({"success": True, "message": "Authentication check successful"}), 200
# Remember Me is now implemented via cookies only (no API endpoints required)
# --------------------------
# Health endpoint
# --------------------------

View File

@@ -1034,8 +1034,6 @@ class GetSettingResponse(BaseResponse):
# =============================================================================
# GRAPHQL SCHEMAS
# =============================================================================
class GraphQLRequest(BaseModel):
"""Request payload for GraphQL queries."""
query: str = Field(..., description="GraphQL query string", json_schema_extra={"examples": ["{ devices { devMac devName } }"]})

View File

@@ -0,0 +1,204 @@
"""
Parameters Instance - Handles Parameters table operations for Remember Me tokens and other system parameters.
The Parameters table is used for temporary, ephemeral settings like Remember Me tokens.
Structure:
parID: TEXT PRIMARY KEY (e.g., "remember_me_token_{uuid}")
parValue: TEXT (e.g., hashed token value)
"""
import hashlib
import sqlite3
from database import get_temp_db_connection
from logger import mylog
class ParametersInstance:
"""Handler for Parameters table operations."""
# --- helper methods (DRY pattern from DeviceInstance) ----------------------
def _fetchall(self, query, params=()):
"""Fetch all rows and return as list of dicts."""
conn = get_temp_db_connection()
rows = conn.execute(query, params).fetchall()
conn.close()
return [dict(r) for r in rows]
def _fetchone(self, query, params=()):
"""Fetch single row and return as dict or None."""
conn = get_temp_db_connection()
row = conn.execute(query, params).fetchone()
conn.close()
return dict(row) if row else None
def _execute(self, query, params=()):
"""Execute write query (INSERT/UPDATE/DELETE)."""
conn = get_temp_db_connection()
cur = conn.cursor()
cur.execute(query, params)
conn.commit()
conn.close()
# --- public API -----------------------------------------------------------
def get_parameter(self, par_id):
"""
Retrieve a parameter value by ID.
Args:
par_id (str): The parameter ID to retrieve
Returns:
str: The parameter value, or None if not found
"""
try:
# Try with quoted column names in case they're reserved or have special chars
row = self._fetchone(
'SELECT "parValue" FROM "Parameters" WHERE "parID" = ?',
(par_id,)
)
return row['parValue'] if row else None
except Exception as e:
mylog("verbose", [f"[ParametersInstance] Error retrieving parameter {par_id}: {e}"])
return None
def set_parameter(self, par_id, par_value):
"""
Store or update a parameter (INSERT OR REPLACE).
Args:
par_id (str): The parameter ID
par_value (str): The parameter value
Returns:
bool: True if successful, False otherwise
"""
try:
# Try with quoted column names in case they're reserved or have special chars
self._execute(
'INSERT OR REPLACE INTO "Parameters" ("parID", "parValue") VALUES (?, ?)',
(par_id, par_value)
)
mylog("verbose", [f"[ParametersInstance] Parameter {par_id} stored successfully"])
return True
except Exception as e:
mylog("verbose", [f"[ParametersInstance] Error storing parameter {par_id}: {e}"])
return False
def delete_parameter(self, par_id):
"""
Delete a parameter by ID.
Args:
par_id (str): The parameter ID to delete
Returns:
bool: True if successful, False otherwise
"""
try:
# Try with quoted column names in case they're reserved or have special chars
self._execute(
'DELETE FROM "Parameters" WHERE "parID" = ?',
(par_id,)
)
mylog("verbose", [f"[ParametersInstance] Parameter {par_id} deleted successfully"])
return True
except Exception as e:
mylog("verbose", [f"[ParametersInstance] Error deleting parameter {par_id}: {e}"])
return False
def delete_parameters_by_prefix(self, prefix):
"""
Delete all parameters matching a prefix pattern (for cleanup).
Args:
prefix (str): The prefix pattern (e.g., "remember_me_token_")
Returns:
int: Number of parameters deleted
"""
try:
conn = get_temp_db_connection()
cur = conn.cursor()
cur.execute('DELETE FROM "Parameters" WHERE "parID" LIKE ?', (f"{prefix}%",))
deleted_count = cur.rowcount
conn.commit()
conn.close()
mylog("verbose", [f"[ParametersInstance] Deleted {deleted_count} parameters with prefix '{prefix}'"])
return deleted_count
except Exception as e:
mylog("verbose", [f"[ParametersInstance] Error deleting parameters with prefix '{prefix}': {e}"])
return 0
def validate_token(self, token):
"""
Validate a Remember Me token against stored hash.
Security: Compares hash(token) against stored hashes using hash_equals (timing-safe).
Args:
token (str): The unhashed token (from cookie)
Returns:
dict: {
'valid': bool,
'par_id': str or None # The matching parameter ID if valid
}
Note:
Returns immediately on first match. Use hash_equals() to prevent timing attacks.
"""
if not token:
return {'valid': False, 'par_id': None}
try:
# Compute hash of provided token
computed_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()
# Retrieve all remember_me tokens from Parameters table
remember_tokens = self._fetchall(
'SELECT "parID", "parValue" FROM "Parameters" WHERE "parID" LIKE ?',
("remember_me_token_%",)
)
# Check each stored token using timing-safe comparison
for token_record in remember_tokens:
stored_hash = token_record['parValue']
stored_id = token_record['parID']
# Use hash_equals() to prevent timing attacks
if self._hash_equals(stored_hash, computed_hash):
mylog("verbose", [f"[ParametersInstance] Token validation successful for {stored_id}"])
return {'valid': True, 'par_id': stored_id}
mylog("verbose", ["[ParametersInstance] Token validation failed: no matching token found"])
return {'valid': False, 'par_id': None}
except Exception as e:
mylog("verbose", [f"[ParametersInstance] Error validating token: {e}"])
return {'valid': False, 'par_id': None}
@staticmethod
def _hash_equals(known_string, user_string):
"""
Timing-safe string comparison to prevent timing attacks.
Args:
known_string (str): The known value (stored hash)
user_string (str): The user-supplied value (computed hash)
Returns:
bool: True if strings match, False otherwise
"""
if not isinstance(known_string, str) or not isinstance(user_string, str):
return False
if len(known_string) != len(user_string):
return False
# Compare all characters regardless of match (timing-safe)
result = 0
for x, y in zip(known_string, user_string):
result |= ord(x) ^ ord(y)
return result == 0

236
test/ui/test_ui_login.py Normal file
View File

@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""
Login Page UI Tests
Tests login functionality and deep link support after login
"""
import sys
import os
import time
from selenium.webdriver.common.by import By
# Add test directory to path
sys.path.insert(0, os.path.dirname(__file__))
from .test_helpers import BASE_URL, wait_for_page_load # noqa: E402
def get_login_password():
"""Get login password from config file or environment
Returns the plaintext password that should be used for login.
For test/dev environments, tries common test passwords and defaults.
Returns None if password cannot be determined (will skip test).
"""
# Try environment variable first (for testing)
if os.getenv("LOGIN_PASSWORD"):
return os.getenv("LOGIN_PASSWORD")
# SHA256 hash of "password" - the default test password (from index.php)
DEFAULT_PASSWORD_HASH = '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92'
# Try common config file locations
config_paths = [
"/data/config/app.conf",
"/app/back/app.conf",
os.path.expanduser("~/.netalertx/app.conf")
]
for config_path in config_paths:
try:
if os.path.exists(config_path):
print(f"📋 Reading config from: {config_path}")
with open(config_path, 'r') as f:
for line in f:
# Only look for SETPWD_password lines (not other config like API keys)
if 'SETPWD_password' in line and '=' in line:
# Extract the value between quotes
value = line.split('=', 1)[1].strip()
# Remove quotes
value = value.strip('"').strip("'")
print(f"✓ Found password config: {value[:32]}...")
# If it's the default, use the default password
if value == DEFAULT_PASSWORD_HASH:
print(" Using default password: '123456'")
return "123456"
# If it's plaintext and looks reasonable
elif len(value) < 100 and not value.startswith('{') and value.isalnum():
print(f" Using plaintext password: '{value}'")
return value
# For other hashes, can't determine plaintext
break # Found SETPWD_password, stop looking
except (FileNotFoundError, IOError, PermissionError) as e:
print(f"⚠ Error reading {config_path}: {e}")
continue
# If we couldn't determine the password from config, try default password
print(" Password not determinable from config, trying default passwords...")
# For now, return first test password to try
# Tests will skip if login fails
return None
def perform_login(driver, password=None):
"""Helper function to perform login with optional password fallback
Args:
driver: Selenium WebDriver
password: Password to try. If None, will try default test password
"""
if password is None:
password = "123456" # Default test password
password_input = driver.find_element(By.NAME, "loginpassword")
password_input.send_keys(password)
submit_button = driver.find_element(By.CSS_SELECTOR, "button[type='submit']")
submit_button.click()
# Wait for page to respond to form submission
# This might either redirect or show login error
time.sleep(1)
wait_for_page_load(driver, timeout=5)
def test_login_page_loads(driver):
"""Test: Login page loads successfully"""
driver.get(f"{BASE_URL}/index.php")
wait_for_page_load(driver)
# Check that login form is present
password_field = driver.find_element(By.NAME, "loginpassword")
assert password_field, "Password field should be present"
submit_button = driver.find_element(By.CSS_SELECTOR, "button[type='submit']")
assert submit_button, "Submit button should be present"
def test_login_redirects_to_devices(driver):
"""Test: Successful login redirects to devices page"""
import pytest
password = get_login_password()
# Use password if found, otherwise helper will use default "password"
driver.get(f"{BASE_URL}/index.php")
wait_for_page_load(driver)
perform_login(driver, password)
# Wait for redirect to complete (server-side redirect is usually instant)
time.sleep(1)
# Should be redirected to devices page
if '/devices.php' not in driver.current_url:
pytest.skip(f"Login failed or not configured. URL: {driver.current_url}")
assert '/devices.php' in driver.current_url, \
f"Expected redirect to devices.php, got {driver.current_url}"
def test_login_with_deep_link_preserves_hash(driver):
"""Test: Login with deep link (?next=...) preserves the URL fragment hash
When a user logs in from a deep link URL (e.g., ?next=base64(devices.php%23device-123)),
they should be redirected to the target page with the hash fragment intact.
"""
import base64
import pytest
password = get_login_password()
# Create a deep link to devices.php#device-123
deep_link_path = "/devices.php#device-123"
encoded_path = base64.b64encode(deep_link_path.encode()).decode()
# Navigate to login with deep link
driver.get(f"{BASE_URL}/index.php?next={encoded_path}")
wait_for_page_load(driver)
perform_login(driver, password)
# Wait for redirect to complete (server-side redirect + potential JS handling)
time.sleep(2)
# Check that we're on the right page with the hash preserved
current_url = driver.current_url
print(f"URL after login with deep link: {current_url}")
if '/devices.php' not in current_url:
pytest.skip(f"Login failed or redirect not configured. URL: {current_url}")
# Verify the hash fragment is preserved
assert '#device-123' in current_url, f"Expected #device-123 hash in URL, got {current_url}"
def test_login_with_deep_link_to_network_page(driver):
"""Test: Login with deep link to network.php page preserves hash
User can login with a deep link to the network page (e.g., network.php#settings-panel),
and should be redirected to that page with the hash fragment intact.
"""
import base64
import pytest
password = get_login_password()
# Create a deep link to network.php#settings-panel
deep_link_path = "/network.php#settings-panel"
encoded_path = base64.b64encode(deep_link_path.encode()).decode()
# Navigate to login with deep link
driver.get(f"{BASE_URL}/index.php?next={encoded_path}")
wait_for_page_load(driver)
perform_login(driver, password)
# Wait for redirect to complete
time.sleep(2)
# Check that we're on the right page with the hash preserved
current_url = driver.current_url
print(f"URL after login with network.php deep link: {current_url}")
if '/network.php' not in current_url:
pytest.skip(f"Login failed or redirect not configured. URL: {current_url}")
# Verify the hash fragment is preserved
assert '#settings-panel' in current_url, f"Expected #settings-panel hash in URL, got {current_url}"
def test_login_without_next_parameter(driver):
"""Test: Login without ?next parameter defaults to devices.php"""
import pytest
password = get_login_password()
driver.get(f"{BASE_URL}/index.php")
wait_for_page_load(driver)
perform_login(driver, password)
# Wait for redirect to complete
time.sleep(1)
# Should redirect to default devices page
current_url = driver.current_url
if '/devices.php' not in current_url:
pytest.skip(f"Login failed or not configured. URL: {current_url}")
assert '/devices.php' in current_url, f"Expected default redirect to devices.php, got {current_url}"
def test_url_hash_hidden_input_present(driver):
"""Test: URL fragment hash field is present in login form
The hidden url_hash input field is used to capture and preserve
URL hash fragments during form submission and redirect.
"""
driver.get(f"{BASE_URL}/index.php")
wait_for_page_load(driver)
# Verify the hidden input field exists
url_hash_input = driver.find_element(By.ID, "url_hash")
assert url_hash_input, "Hidden url_hash input field should be present"
assert url_hash_input.get_attribute("type") == "hidden", "url_hash should be a hidden input field"