mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-03-30 23:03:03 -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 |
210
front/index.php
210
front/index.php
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# --------------------------
|
||||
|
||||
@@ -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 } }"]})
|
||||
|
||||
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