Files
NetAlertX/test/api_endpoints/test_remember_me_endpoints.py
2026-02-22 03:47:29 +00:00

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