mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-02-23 18:56:05 -05:00
401 lines
14 KiB
Python
401 lines
14 KiB
Python
"""
|
|
Remember Me Token Tests - Security & Functionality
|
|
|
|
Tests the secure Remember Me feature:
|
|
- Token generation and storage
|
|
- Token validation (including timing-safe comparison)
|
|
- Security: Tampered token rejection
|
|
- API endpoint validation
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import hashlib
|
|
import json
|
|
|
|
# Register NetAlertX directories
|
|
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
|
|
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
|
|
|
|
import pytest
|
|
from api_server.api_server_start import app # noqa: E402
|
|
from models.parameters_instance import ParametersInstance # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""Flask test client."""
|
|
with app.test_client() as client:
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
def params_instance():
|
|
"""ParametersInstance for direct database access."""
|
|
return ParametersInstance()
|
|
|
|
|
|
@pytest.fixture
|
|
def test_token():
|
|
"""Generate a valid test token (64-hex characters)."""
|
|
import os
|
|
return os.urandom(32).hex() # 32 bytes = 64 hex chars
|
|
|
|
|
|
# ============================================================================
|
|
# REMEMBER ME SAVE ENDPOINT TESTS
|
|
# ============================================================================
|
|
|
|
def test_save_remember_success(client, test_token):
|
|
"""POST /auth/remember-me/save - Valid token should save successfully."""
|
|
resp = client.post("/auth/remember-me/save", json={"token": test_token})
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is True
|
|
assert "saved successfully" in data.get("message", "").lower()
|
|
assert data.get("token_id") is not None
|
|
assert data["token_id"].startswith("remember_me_token_")
|
|
|
|
|
|
def test_save_remember_missing_token(client):
|
|
"""POST /auth/remember-me/save - Missing token should fail with 422 (validation error)."""
|
|
resp = client.post("/auth/remember-me/save", json={})
|
|
|
|
assert resp.status_code == 422 # Pydantic validation error, not 400
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is False
|
|
|
|
|
|
def test_save_remember_empty_token(client):
|
|
"""POST /auth/remember-me/save - Empty token should fail with 422."""
|
|
resp = client.post("/auth/remember-me/save", json={"token": ""})
|
|
|
|
assert resp.status_code == 422 # Pydantic validation error
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is False
|
|
|
|
|
|
def test_save_remember_short_token(client):
|
|
"""POST /auth/remember-me/save - Token too short (< 64 chars) should fail with 422."""
|
|
resp = client.post("/auth/remember-me/save", json={"token": "a" * 32})
|
|
|
|
assert resp.status_code == 422 # Pydantic validation error
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is False
|
|
|
|
|
|
def test_save_remember_null_token(client):
|
|
"""POST /auth/remember-me/save - Null token should fail with 422."""
|
|
resp = client.post("/auth/remember-me/save", json={"token": None})
|
|
|
|
assert resp.status_code == 422 # Pydantic validation error
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is False
|
|
|
|
|
|
# ============================================================================
|
|
# REMEMBER ME VALIDATION ENDPOINT TESTS
|
|
# ============================================================================
|
|
|
|
def test_validate_remember_valid_token(client, test_token, params_instance):
|
|
"""POST /auth/validate-remember - Valid token should validate successfully."""
|
|
# First save the token
|
|
save_resp = client.post("/auth/remember-me/save", json={"token": test_token})
|
|
assert save_resp.status_code == 200
|
|
|
|
# Now validate it
|
|
validate_resp = client.post("/auth/validate-remember", json={"token": test_token})
|
|
|
|
assert validate_resp.status_code == 200
|
|
data = validate_resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is True
|
|
assert data["valid"] is True
|
|
assert "successful" in data.get("message", "").lower()
|
|
|
|
|
|
def test_validate_remember_tampered_token(client, test_token):
|
|
"""
|
|
POST /auth/validate-remember - SECURITY TEST: Tampered token should be rejected.
|
|
|
|
This test verifies the security of the timing-safe hash comparison.
|
|
Even a single-character modification to the token should fail validation.
|
|
"""
|
|
# Save the original token
|
|
save_resp = client.post("/auth/remember-me/save", json={"token": test_token})
|
|
assert save_resp.status_code == 200
|
|
|
|
# Tamper with the token by modifying last character
|
|
tampered_token = test_token[:-1] + ("0" if test_token[-1] != "0" else "1")
|
|
|
|
# Attempt to validate with tampered token
|
|
validate_resp = client.post("/auth/validate-remember", json={"token": tampered_token})
|
|
|
|
assert validate_resp.status_code == 200
|
|
data = validate_resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is True # API doesn't error
|
|
assert data["valid"] is False # But validation fails
|
|
assert "failed" in data.get("message", "").lower()
|
|
|
|
|
|
def test_validate_remember_nonexistent_token(client):
|
|
"""POST /auth/validate-remember - Non-existent token should return invalid."""
|
|
import os
|
|
random_token = os.urandom(32).hex()
|
|
|
|
resp = client.post("/auth/validate-remember", json={"token": random_token})
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is True
|
|
assert data["valid"] is False
|
|
|
|
|
|
def test_validate_remember_missing_token(client):
|
|
"""POST /auth/validate-remember - Missing token should fail with 422."""
|
|
resp = client.post("/auth/validate-remember", json={})
|
|
|
|
# Pydantic validation catches this before handler
|
|
assert resp.status_code == 422
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is False
|
|
|
|
|
|
def test_validate_remember_empty_token(client):
|
|
"""POST /auth/validate-remember - Empty token should fail with 422."""
|
|
resp = client.post("/auth/validate-remember", json={"token": ""})
|
|
|
|
# Pydantic validation catches this before handler
|
|
assert resp.status_code == 422
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is False
|
|
|
|
|
|
def test_validate_remember_null_token(client):
|
|
"""POST /auth/validate-remember - Null token should fail with 422."""
|
|
resp = client.post("/auth/validate-remember", json={"token": None})
|
|
|
|
# Pydantic validation catches this before handler
|
|
assert resp.status_code == 422
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
assert data["success"] is False
|
|
|
|
|
|
# ============================================================================
|
|
# COMPLETE WORKFLOW TESTS
|
|
# ============================================================================
|
|
|
|
def test_remember_me_complete_workflow(client, test_token):
|
|
"""
|
|
Save and validate: Complete Remember Me workflow.
|
|
|
|
Simulates:
|
|
1. User logs in, "Remember Me" checked
|
|
2. System saves token via API
|
|
3. User closes browser, session expires
|
|
4. User returns, system validates token from cookie
|
|
5. User authenticated without re-entering password
|
|
"""
|
|
# Step 1: Save token (happens at login with "Remember Me")
|
|
save_resp = client.post("/auth/remember-me/save", json={"token": test_token})
|
|
assert save_resp.status_code == 200
|
|
save_data = save_resp.get_json()
|
|
assert save_data["success"] is True
|
|
token_id = save_data["token_id"]
|
|
|
|
# Step 2: Validate token (happens on return visit from cookie)
|
|
validate_resp = client.post("/auth/validate-remember", json={"token": test_token})
|
|
assert validate_resp.status_code == 200
|
|
validate_data = validate_resp.get_json()
|
|
assert validate_data["success"] is True
|
|
assert validate_data["valid"] is True
|
|
|
|
# Step 3: Verify token was actually stored in Parameters table
|
|
stored_value = ParametersInstance().get_parameter(token_id)
|
|
assert stored_value is not None
|
|
expected_hash = hashlib.sha256(test_token.encode('utf-8')).hexdigest()
|
|
assert stored_value == expected_hash
|
|
|
|
|
|
def test_remember_me_multiple_tokens(client):
|
|
"""Multiple tokens can coexist (for multi-device Remember Me in future)."""
|
|
import os
|
|
|
|
token1 = os.urandom(32).hex()
|
|
token2 = os.urandom(32).hex()
|
|
|
|
# Save both tokens
|
|
resp1 = client.post("/auth/remember-me/save", json={"token": token1})
|
|
resp2 = client.post("/auth/remember-me/save", json={"token": token2})
|
|
|
|
assert resp1.status_code == 200
|
|
assert resp2.status_code == 200
|
|
|
|
# Both should validate
|
|
validate1 = client.post("/auth/validate-remember", json={"token": token1})
|
|
validate2 = client.post("/auth/validate-remember", json={"token": token2})
|
|
|
|
assert validate1.get_json()["valid"] is True
|
|
assert validate2.get_json()["valid"] is True
|
|
|
|
# Each token should only match itself, not the other
|
|
cross_validate = client.post("/auth/validate-remember", json={"token": token1 + token2[:32]})
|
|
assert cross_validate.get_json()["valid"] is False
|
|
|
|
|
|
# ============================================================================
|
|
# SECURITY TESTS
|
|
# ============================================================================
|
|
|
|
def test_timing_attack_prevention(client, test_token):
|
|
"""
|
|
Verify timing-safe hash comparison prevents timing attacks.
|
|
|
|
The _hash_equals() method should take roughly equal time regardless of
|
|
where the mismatch occurs in the string.
|
|
"""
|
|
# Save token
|
|
client.post("/auth/remember-me/save", json={"token": test_token})
|
|
|
|
# Create tampered versions at different positions
|
|
tamper_positions = [0, 10, 32, 60, 63] # Various positions
|
|
|
|
for pos in tamper_positions:
|
|
tampered_token = list(test_token)
|
|
tampered_token[pos] = "F" if tampered_token[pos] != "F" else "0"
|
|
tampered_token = "".join(tampered_token)
|
|
|
|
resp = client.post("/auth/validate-remember", json={"token": tampered_token})
|
|
data = resp.get_json()
|
|
|
|
# All tampered attempts should fail validation
|
|
assert data["valid"] is False, f"Tamper at position {pos} was not detected!"
|
|
|
|
|
|
def test_hash_not_stored_on_disk(client, test_token):
|
|
"""Verify that the HASH (not the token) is stored on disk."""
|
|
save_resp = client.post("/auth/remember-me/save", json={"token": test_token})
|
|
token_id = save_resp.get_json()["token_id"]
|
|
|
|
# Retrieve the stored value
|
|
stored_hash = ParametersInstance().get_parameter(token_id)
|
|
|
|
# Verify it's a hash, not the original token
|
|
assert stored_hash != test_token, "Token hash should not match original token!"
|
|
assert len(stored_hash) == 64, "SHA256 hash should be 64 hex characters"
|
|
assert stored_hash == hashlib.sha256(test_token.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def test_database_compromise_mitigation(client, test_token):
|
|
"""
|
|
If database is compromised, stolen hash should not authenticate.
|
|
|
|
This verifies the security model: attacker gets hash, but can't use it
|
|
without the original token (which only exists in the user's cookie).
|
|
"""
|
|
# Save token
|
|
save_resp = client.post("/auth/remember-me/save", json={"token": test_token})
|
|
token_id = save_resp.get_json()["token_id"]
|
|
|
|
# Simulate database breach: attacker gets the stored hash
|
|
stored_hash = ParametersInstance().get_parameter(token_id)
|
|
|
|
# Attacker tries to use the stolen hash as a token
|
|
breach_test_resp = client.post("/auth/validate-remember", json={"token": stored_hash})
|
|
breach_test_data = breach_test_resp.get_json()
|
|
|
|
# Validation should fail because hash doesn't match hash(hash(token))
|
|
assert breach_test_data["valid"] is False, "Stolen hash should not authenticate!"
|
|
|
|
|
|
# ============================================================================
|
|
# MALFORMED REQUEST TESTS
|
|
# ============================================================================
|
|
|
|
def test_save_remember_no_json_body(client):
|
|
"""POST /auth/remember-me/save - Missing JSON body should fail."""
|
|
resp = client.post(
|
|
"/auth/remember-me/save",
|
|
data="not json",
|
|
content_type="application/json"
|
|
)
|
|
assert resp.status_code in [400, 500]
|
|
|
|
|
|
def test_validate_remember_no_json_body(client):
|
|
"""POST /auth/validate-remember - Missing JSON body should handle gracefully."""
|
|
resp = client.post(
|
|
"/auth/validate-remember",
|
|
data="not json",
|
|
content_type="application/json"
|
|
)
|
|
assert resp.status_code in [200, 400, 500]
|
|
|
|
|
|
def test_save_remember_extra_fields(client, test_token):
|
|
"""POST /auth/remember-me/save - Extra fields should be ignored."""
|
|
resp = client.post("/auth/remember-me/save", json={
|
|
"token": test_token,
|
|
"extra_field": "should be ignored",
|
|
"another": 123
|
|
})
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["success"] is True
|
|
|
|
|
|
# ============================================================================
|
|
# CLEANUP/MAINTENANCE TESTS
|
|
# ============================================================================
|
|
|
|
def test_delete_parameter(params_instance, test_token):
|
|
"""ParametersInstance.delete_parameter() should clean up tokens."""
|
|
# Save a token
|
|
test_id = "test_token_cleanup"
|
|
test_hash = hashlib.sha256(test_token.encode('utf-8')).hexdigest()
|
|
|
|
params_instance.set_parameter(test_id, test_hash)
|
|
assert params_instance.get_parameter(test_id) is not None
|
|
|
|
# Delete it
|
|
success = params_instance.delete_parameter(test_id)
|
|
assert success is True
|
|
assert params_instance.get_parameter(test_id) is None
|
|
|
|
|
|
def test_delete_parameters_by_prefix(params_instance):
|
|
"""ParametersInstance.delete_parameters_by_prefix() should batch delete tokens."""
|
|
import os
|
|
|
|
# Create multiple tokens with same prefix
|
|
prefix = "remember_me_token_test_"
|
|
for i in range(3):
|
|
token_id = f"{prefix}{i}"
|
|
params_instance.set_parameter(token_id, f"hash_{i}")
|
|
|
|
# Verify they exist
|
|
assert params_instance.get_parameter(f"{prefix}0") is not None
|
|
assert params_instance.get_parameter(f"{prefix}1") is not None
|
|
assert params_instance.get_parameter(f"{prefix}2") is not None
|
|
|
|
# Delete by prefix
|
|
deleted_count = params_instance.delete_parameters_by_prefix(prefix)
|
|
assert deleted_count >= 3
|
|
|
|
# Verify they're gone
|
|
assert params_instance.get_parameter(f"{prefix}0") is None
|
|
assert params_instance.get_parameter(f"{prefix}1") is None
|
|
assert params_instance.get_parameter(f"{prefix}2") is None
|