mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-04-08 11:11:38 -07:00
Merge branch 'main' of https://github.com/jokob-sk/NetAlertX
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 201 KiB |
206
front/index.php
206
front/index.php
@@ -3,76 +3,155 @@
|
|||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
require_once $_SERVER['DOCUMENT_ROOT'].'/php/server/db.php';
|
||||||
// check if authenticated
|
require_once $_SERVER['DOCUMENT_ROOT'].'/php/templates/language/lang.php';
|
||||||
// Be CAREFUL WHEN INCLUDING NEW PHP FILES
|
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')
|
session_start();
|
||||||
{
|
|
||||||
header('Location: devices.php');
|
const DEFAULT_REDIRECT = '/devices.php';
|
||||||
$_SESSION["login"] = 1;
|
|
||||||
|
/* =====================================================
|
||||||
|
Helper Functions
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
function safe_redirect(string $path): void {
|
||||||
|
header("Location: {$path}", true, 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout
|
function validate_local_path(?string $encoded): string {
|
||||||
if (isset ($_GET["action"]) && $_GET["action"] == 'logout')
|
if (!$encoded) return DEFAULT_REDIRECT;
|
||||||
{
|
|
||||||
setcookie($CookieSaveLoginName, '', time()+1); // reset cookie
|
$decoded = base64_decode($encoded, true);
|
||||||
$_SESSION["login"] = 0;
|
if ($decoded === false) {
|
||||||
header('Location: index.php');
|
return DEFAULT_REDIRECT;
|
||||||
exit;
|
}
|
||||||
|
|
||||||
|
// 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
|
function extract_hash_from_path(string $path): array {
|
||||||
if (isset ($_POST["loginpassword"]) && $nax_Password === hash('sha256',$_POST["loginpassword"]))
|
/*
|
||||||
{
|
Split a path into path and hash components.
|
||||||
header('Location: devices.php');
|
|
||||||
$_SESSION["login"] = 1;
|
For deep links encoded in the 'next' parameter like /devices.php#device-123,
|
||||||
if (isset($_POST['PWRemember'])) {setcookie($CookieSaveLoginName, hash('sha256',$_POST["loginpassword"]), time()+604800);}
|
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)
|
function append_hash(string $url): string {
|
||||||
if (( isset ($_SESSION["login"]) && ($_SESSION["login"] == 1)) || (isset ($_COOKIE[$CookieSaveLoginName]) && $nax_Password === $_COOKIE[$CookieSaveLoginName]))
|
// First check if the URL already has a hash from the deep link
|
||||||
{
|
$parts = extract_hash_from_path($url);
|
||||||
header('Location: devices.php');
|
if (!empty($parts['hash'])) {
|
||||||
$_SESSION["login"] = 1;
|
return $parts['path'] . $parts['hash'];
|
||||||
if (isset($_POST['PWRemember'])) {setcookie($CookieSaveLoginName, hash('sha256',$_POST["loginpassword"]), time()+604800);}
|
}
|
||||||
|
|
||||||
|
// 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_headline = lang('Login_Toggle_Info_headline');
|
||||||
$login_info = lang('Login_Info');
|
$login_info = lang('Login_Info');
|
||||||
$login_mode = 'danger';
|
$login_mode = 'info';
|
||||||
$login_display_mode = 'display: block;';
|
$login_display_mode = 'display:none;';
|
||||||
$login_icon = 'fa-info';
|
$login_icon = 'fa-info';
|
||||||
|
|
||||||
// no active session, cookie not checked
|
if ($nax_Password === '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92') {
|
||||||
if (isset ($_SESSION["login"]) == FALSE || $_SESSION["login"] != 1)
|
|
||||||
{
|
|
||||||
if ($nax_Password === '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92')
|
|
||||||
{
|
|
||||||
$login_info = lang('Login_Default_PWD');
|
$login_info = lang('Login_Default_PWD');
|
||||||
$login_mode = 'danger';
|
$login_mode = 'danger';
|
||||||
$login_display_mode = 'display: block;';
|
$login_display_mode = 'display:block;';
|
||||||
$login_headline = lang('Login_Toggle_Alert_headline');
|
$login_headline = lang('Login_Toggle_Alert_headline');
|
||||||
$login_icon = 'fa-ban';
|
$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>
|
<!DOCTYPE html>
|
||||||
@@ -109,24 +188,18 @@ if (isset ($_SESSION["login"]) == FALSE || $_SESSION["login"] != 1)
|
|||||||
<!-- /.login-logo -->
|
<!-- /.login-logo -->
|
||||||
<div class="login-box-body">
|
<div class="login-box-body">
|
||||||
<p class="login-box-msg"><?= lang('Login_Box');?></p>
|
<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">
|
<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">
|
<input type="password" class="form-control" placeholder="<?= lang('Login_Psw-box');?>" name="loginpassword">
|
||||||
<span class="glyphicon glyphicon-lock form-control-feedback"></span>
|
<span class="glyphicon glyphicon-lock form-control-feedback"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xs-8">
|
<div class="col-xs-12">
|
||||||
<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;">
|
|
||||||
<button type="submit" class="btn btn-primary btn-block btn-flat"><?= lang('Login_Submit');?></button>
|
<button type="submit" class="btn btn-primary btn-block btn-flat"><?= lang('Login_Submit');?></button>
|
||||||
</div>
|
</div>
|
||||||
<!-- /.col -->
|
<!-- /.col -->
|
||||||
@@ -159,6 +232,9 @@ if (isset ($_SESSION["login"]) == FALSE || $_SESSION["login"] != 1)
|
|||||||
<!-- iCheck -->
|
<!-- iCheck -->
|
||||||
<script src="lib/iCheck/icheck.min.js"></script>
|
<script src="lib/iCheck/icheck.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
if (window.location.hash) {
|
||||||
|
document.getElementById('url_hash').value = window.location.hash;
|
||||||
|
}
|
||||||
$(function () {
|
$(function () {
|
||||||
$('input').iCheck({
|
$('input').iCheck({
|
||||||
checkboxClass: 'icheckbox_square-blue',
|
checkboxClass: 'icheckbox_square-blue',
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
// Start session if not already started
|
||||||
|
if (session_status() == PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
$configFolderPath = rtrim(getenv('NETALERTX_CONFIG') ?: '/data/config', '/');
|
$configFolderPath = rtrim(getenv('NETALERTX_CONFIG') ?: '/data/config', '/');
|
||||||
$legacyConfigPath = $_SERVER['DOCUMENT_ROOT'] . "/../config/app.conf";
|
$legacyConfigPath = $_SERVER['DOCUMENT_ROOT'] . "/../config/app.conf";
|
||||||
@@ -45,10 +50,6 @@ $isLogonPage = ($parsedUrl === '/' || $parsedUrl === '/index.php');
|
|||||||
$authHeader = apache_request_headers()['Authorization'] ?? '';
|
$authHeader = apache_request_headers()['Authorization'] ?? '';
|
||||||
$sessionLogin = isset($_SESSION['login']) ? $_SESSION['login'] : 0;
|
$sessionLogin = isset($_SESSION['login']) ? $_SESSION['login'] : 0;
|
||||||
|
|
||||||
// Start session if not already started
|
|
||||||
if (session_status() == PHP_SESSION_NONE) {
|
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle logout
|
// Handle logout
|
||||||
if (!empty($_REQUEST['action']) && $_REQUEST['action'] == 'logout') {
|
if (!empty($_REQUEST['action']) && $_REQUEST['action'] == 'logout') {
|
||||||
@@ -82,11 +83,12 @@ if ($nax_WebProtection == 'true') {
|
|||||||
$isLoggedIn = isset($_SESSION['login']) && $_SESSION['login'] == 1;
|
$isLoggedIn = isset($_SESSION['login']) && $_SESSION['login'] == 1;
|
||||||
|
|
||||||
// Determine if the user should be redirected
|
// 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
|
// Logged in or stay on this page if we are on the index.php already
|
||||||
} else {
|
} else {
|
||||||
// We need to redirect
|
// 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
|
exit; // exit is needed to prevent authentication bypass
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ from .openapi.schemas import ( # noqa: E402 [flake8 lint suppression]
|
|||||||
BaseResponse, DeviceTotalsResponse,
|
BaseResponse, DeviceTotalsResponse,
|
||||||
DeviceTotalsNamedResponse,
|
DeviceTotalsNamedResponse,
|
||||||
EventsTotalsNamedResponse,
|
EventsTotalsNamedResponse,
|
||||||
DeleteDevicesRequest, DeviceImportRequest,
|
DeleteDevicesRequest,
|
||||||
DeviceImportResponse, UpdateDeviceColumnRequest,
|
DeviceImportResponse, UpdateDeviceColumnRequest,
|
||||||
LockDeviceFieldRequest, UnlockDeviceFieldsRequest,
|
LockDeviceFieldRequest, UnlockDeviceFieldsRequest,
|
||||||
CopyDeviceRequest, TriggerScanRequest,
|
CopyDeviceRequest, TriggerScanRequest,
|
||||||
@@ -94,7 +94,7 @@ from .openapi.schemas import ( # noqa: E402 [flake8 lint suppression]
|
|||||||
DbQueryRequest, DbQueryResponse,
|
DbQueryRequest, DbQueryResponse,
|
||||||
DbQueryUpdateRequest, DbQueryDeleteRequest,
|
DbQueryUpdateRequest, DbQueryDeleteRequest,
|
||||||
AddToQueueRequest, GetSettingResponse,
|
AddToQueueRequest, GetSettingResponse,
|
||||||
RecentEventsRequest, SetDeviceAliasRequest
|
RecentEventsRequest, SetDeviceAliasRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression]
|
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
|
return jsonify({"success": True, "message": "Authentication check successful"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# Remember Me is now implemented via cookies only (no API endpoints required)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Health endpoint
|
# Health endpoint
|
||||||
# --------------------------
|
# --------------------------
|
||||||
|
|||||||
@@ -1034,8 +1034,6 @@ class GetSettingResponse(BaseResponse):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# GRAPHQL SCHEMAS
|
# GRAPHQL SCHEMAS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class GraphQLRequest(BaseModel):
|
class GraphQLRequest(BaseModel):
|
||||||
"""Request payload for GraphQL queries."""
|
"""Request payload for GraphQL queries."""
|
||||||
query: str = Field(..., description="GraphQL query string", json_schema_extra={"examples": ["{ devices { devMac devName } }"]})
|
query: str = Field(..., description="GraphQL query string", json_schema_extra={"examples": ["{ devices { devMac devName } }"]})
|
||||||
|
|||||||
204
server/models/parameters_instance.py
Normal file
204
server/models/parameters_instance.py
Normal 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
236
test/ui/test_ui_login.py
Normal 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"
|
||||||
Reference in New Issue
Block a user