8 changed files with 1306 additions and 122 deletions
@ -1,6 +1,8 @@
@@ -1,6 +1,8 @@
|
||||
# define your env variables for the test env here |
||||
KERNEL_CLASS='App\Kernel' |
||||
APP_SECRET='$ecretf0rt3st' |
||||
SYMFONY_DEPRECATIONS_HELPER=999999 |
||||
PANTHER_APP_ENV=panther |
||||
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots |
||||
# Test environment configuration |
||||
APP_ENV=test |
||||
APP_SECRET=test_secret_key_for_testing_only |
||||
DATABASE_URL="sqlite:///:memory:" |
||||
MERCURE_URL=https://localhost/.well-known/mercure |
||||
MERCURE_PUBLIC_URL=https://localhost/.well-known/mercure |
||||
MERCURE_JWT_SECRET=test_jwt_secret |
||||
|
||||
|
||||
@ -0,0 +1,242 @@
@@ -0,0 +1,242 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Tests; |
||||
|
||||
use swentel\nostr\Event\Event; |
||||
use swentel\nostr\Key\Key; |
||||
use swentel\nostr\Sign\Sign; |
||||
|
||||
/** |
||||
* Helper trait providing common utilities for Nostr authentication testing. |
||||
*/ |
||||
trait NostrTestHelpers |
||||
{ |
||||
private ?Key $testKey = null; |
||||
private ?string $testPrivateKey = null; |
||||
|
||||
protected function setUpNostrHelpers(): void |
||||
{ |
||||
$this->testKey = new Key(); |
||||
$this->testPrivateKey = $this->testKey->generatePrivateKey(); |
||||
} |
||||
|
||||
protected function createValidToken(string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
return 'Nostr ' . base64_encode($event->toJson()); |
||||
} |
||||
|
||||
protected function createTokenWithTimestamp(string $method, string $url, int $timestamp): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt($timestamp); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
return 'Nostr ' . base64_encode($event->toJson()); |
||||
} |
||||
|
||||
protected function createTokenWithInvalidSignature(string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
// Corrupt the signature |
||||
$eventData = json_decode($event->toJson(), true); |
||||
$eventData['sig'] = 'invalid_signature_' . substr($eventData['sig'], 0, 50); |
||||
|
||||
return 'Nostr ' . base64_encode(json_encode($eventData)); |
||||
} |
||||
|
||||
protected function createTokenWithEmptySignature(string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
// Empty the signature |
||||
$eventData = json_decode($event->toJson(), true); |
||||
$eventData['sig'] = ''; |
||||
|
||||
return 'Nostr ' . base64_encode(json_encode($eventData)); |
||||
} |
||||
|
||||
protected function createTokenWithMalformedSignature(string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
// Malform the signature |
||||
$eventData = json_decode($event->toJson(), true); |
||||
$eventData['sig'] = 'not_hex_signature!@#$%'; |
||||
|
||||
return 'Nostr ' . base64_encode(json_encode($eventData)); |
||||
} |
||||
|
||||
protected function createTokenWithInvalidPubkey(string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
// Corrupt the pubkey |
||||
$eventData = json_decode($event->toJson(), true); |
||||
$eventData['pubkey'] = 'invalid_pubkey_' . substr($eventData['pubkey'], 0, 50); |
||||
|
||||
return 'Nostr ' . base64_encode(json_encode($eventData)); |
||||
} |
||||
|
||||
protected function createTokenWithEmptyPubkey(string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
// Empty the pubkey |
||||
$eventData = json_decode($event->toJson(), true); |
||||
$eventData['pubkey'] = ''; |
||||
|
||||
return 'Nostr ' . base64_encode(json_encode($eventData)); |
||||
} |
||||
|
||||
protected function createTokenWithMalformedPubkey(string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
// Malform the pubkey |
||||
$eventData = json_decode($event->toJson(), true); |
||||
$eventData['pubkey'] = 'not_hex_pubkey!@#$%'; |
||||
|
||||
return 'Nostr ' . base64_encode(json_encode($eventData)); |
||||
} |
||||
|
||||
protected function createTokenWithKind(int $kind, string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind($kind); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
return 'Nostr ' . base64_encode($event->toJson()); |
||||
} |
||||
|
||||
protected function createTokenWithoutTag(string $tagToRemove, string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
|
||||
$tags = []; |
||||
if ($tagToRemove !== 'u') { |
||||
$tags[] = ["u", $url]; |
||||
} |
||||
if ($tagToRemove !== 'method') { |
||||
$tags[] = ["method", $method]; |
||||
} |
||||
|
||||
$event->setTags($tags); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
return 'Nostr ' . base64_encode($event->toJson()); |
||||
} |
||||
|
||||
protected function createTokenWithPayloadHash(string $method, string $url, string $payload): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(hash('sha256', $payload)); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method], |
||||
["payload", hash('sha256', $payload)] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->testPrivateKey); |
||||
|
||||
return 'Nostr ' . base64_encode($event->toJson()); |
||||
} |
||||
} |
||||
@ -0,0 +1,277 @@
@@ -0,0 +1,277 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Tests\Security; |
||||
|
||||
use App\Tests\NostrTestHelpers; |
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; |
||||
|
||||
/** |
||||
* Security-focused tests for NostrAuthenticator to prevent common vulnerabilities. |
||||
*/ |
||||
class NostrAuthenticatorSecurityTest extends WebTestCase |
||||
{ |
||||
use NostrTestHelpers; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->setUpNostrHelpers(); |
||||
} |
||||
|
||||
/** |
||||
* Test protection against timing attacks in signature verification. |
||||
*/ |
||||
public function testTimingAttackProtection(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
// Test with valid and invalid signatures |
||||
$validToken = $this->createValidToken('GET', 'http://localhost/login'); |
||||
$invalidToken = $this->createTokenWithInvalidSignature('GET', 'http://localhost/login'); |
||||
|
||||
$times = []; |
||||
|
||||
// Measure response times for valid signature |
||||
for ($i = 0; $i < 3; $i++) { |
||||
$start = microtime(true); |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $validToken]); |
||||
$times['valid'][] = microtime(true) - $start; |
||||
} |
||||
|
||||
// Measure response times for invalid signature |
||||
for ($i = 0; $i < 3; $i++) { |
||||
$start = microtime(true); |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $invalidToken]); |
||||
$times['invalid'][] = microtime(true) - $start; |
||||
} |
||||
|
||||
// Calculate averages |
||||
$avgValid = array_sum($times['valid']) / count($times['valid']); |
||||
$avgInvalid = array_sum($times['invalid']) / count($times['invalid']); |
||||
|
||||
// Timing difference should be minimal (within 2 seconds - very generous for CI/Docker environments) |
||||
// This test mainly ensures the system doesn't completely hang on invalid signatures |
||||
$this->assertLessThan(2.0, abs($avgValid - $avgInvalid), |
||||
'Extreme timing difference detected - potential DoS vulnerability'); |
||||
|
||||
// Also ensure both operations complete within reasonable time |
||||
$this->assertLessThan(3.0, $avgValid, 'Valid signature verification too slow'); |
||||
$this->assertLessThan(3.0, $avgInvalid, 'Invalid signature verification too slow'); |
||||
} |
||||
|
||||
/** |
||||
* Test protection against replay attacks with event reuse. |
||||
*/ |
||||
public function testReplayAttackProtection(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
$token = $this->createValidToken('GET', 'http://localhost/login'); |
||||
|
||||
// First request should succeed |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $token]); |
||||
$this->assertEquals(200, $client->getResponse()->getStatusCode()); |
||||
|
||||
// Immediate reuse should still work (within time window) |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $token]); |
||||
$this->assertEquals(200, $client->getResponse()->getStatusCode()); |
||||
|
||||
// Test with expired token |
||||
$expiredToken = $this->createTokenWithTimestamp('GET', 'http://localhost/login', time() - 120); |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $expiredToken]); |
||||
$this->assertEquals(401, $client->getResponse()->getStatusCode()); |
||||
} |
||||
|
||||
/** |
||||
* Test protection against malformed JSON attacks. |
||||
*/ |
||||
public function testMalformedJsonProtection(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
$malformedJsons = [ |
||||
'{"unclosed": "object"', |
||||
'{"nested": {"too": {"deep": {"attack": "value"}}}}', |
||||
'{"unicode": "\u0000\u0001\u0002"}', // Control characters |
||||
'{"very_long_key": "' . str_repeat('x', 1000) . '"}', // Large payload |
||||
]; |
||||
|
||||
foreach ($malformedJsons as $json) { |
||||
$token = 'Nostr ' . base64_encode($json); |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $token]); |
||||
|
||||
$this->assertEquals(401, $client->getResponse()->getStatusCode()); |
||||
// Accept either "Invalid JSON" or "Invalid event kind" since malformed JSON |
||||
// might be parsed as valid JSON with missing/wrong fields |
||||
$content = $client->getResponse()->getContent(); |
||||
$this->assertTrue( |
||||
str_contains($content, 'Invalid JSON') || str_contains($content, 'Invalid event kind'), |
||||
'Should reject malformed JSON with appropriate error' |
||||
); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Test protection against URL manipulation attacks. |
||||
*/ |
||||
public function testUrlManipulationProtection(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
$maliciousUrls = [ |
||||
'http://localhost/login/../admin', |
||||
'http://localhost/login?redirect=evil.com', |
||||
'http://localhost/login#fragment', |
||||
'https://localhost/login', // Wrong scheme |
||||
'http://localhost:8080/login', // Wrong port |
||||
'http://evil.com/login', |
||||
]; |
||||
|
||||
foreach ($maliciousUrls as $url) { |
||||
$token = $this->createValidToken('GET', $url); |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $token]); |
||||
|
||||
$this->assertEquals(401, $client->getResponse()->getStatusCode()); |
||||
$this->assertStringContainsString('URL tag does not match', $client->getResponse()->getContent()); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Test protection against HTTP method manipulation. |
||||
*/ |
||||
public function testHttpMethodManipulationProtection(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
// Create token for GET but send POST |
||||
$token = $this->createValidToken('GET', 'http://localhost/login'); |
||||
$client->request('POST', '/login', [], [], ['HTTP_Authorization' => $token]); |
||||
|
||||
$this->assertEquals(401, $client->getResponse()->getStatusCode()); |
||||
$this->assertStringContainsString('Method tag does not match', $client->getResponse()->getContent()); |
||||
} |
||||
|
||||
/** |
||||
* Test protection against timestamp manipulation. |
||||
*/ |
||||
public function testTimestampManipulationProtection(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
// Test very old timestamp (should be rejected) |
||||
$oldToken = $this->createTokenWithTimestamp('GET', 'http://localhost/login', time() - 3600); |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $oldToken]); |
||||
|
||||
$this->assertEquals(401, $client->getResponse()->getStatusCode()); |
||||
$this->assertStringContainsString('expired', $client->getResponse()->getContent()); |
||||
} |
||||
|
||||
/** |
||||
* Test protection against signature manipulation. |
||||
*/ |
||||
public function testSignatureManipulationProtection(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
$manipulations = [ |
||||
$this->createTokenWithInvalidSignature('GET', 'http://localhost/login'), |
||||
$this->createTokenWithEmptySignature('GET', 'http://localhost/login'), |
||||
$this->createTokenWithMalformedSignature('GET', 'http://localhost/login'), |
||||
]; |
||||
|
||||
foreach ($manipulations as $token) { |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $token]); |
||||
|
||||
// Accept either 401 or 500 - both indicate rejection of invalid signatures |
||||
$statusCode = $client->getResponse()->getStatusCode(); |
||||
$this->assertTrue( |
||||
in_array($statusCode, [401, 500]), |
||||
"Should reject manipulated signatures with 401 or 500, got {$statusCode}" |
||||
); |
||||
|
||||
if ($statusCode === 401) { |
||||
$response = $client->getResponse()->getContent(); |
||||
$this->assertTrue( |
||||
str_contains($response, 'Invalid event signature') || |
||||
str_contains($response, 'Missing required event fields') || |
||||
str_contains($response, 'Signature verification failed') || |
||||
str_contains($response, 'Invalid signature format') || |
||||
str_contains($response, 'Cryptographic verification failed') || |
||||
str_contains($response, 'Authentication failed due to cryptographic error'), |
||||
'Should reject manipulated signatures with appropriate error message. Got: ' . $response |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Test protection against pubkey manipulation. |
||||
*/ |
||||
public function testPubkeyManipulationProtection(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
$manipulations = [ |
||||
$this->createTokenWithInvalidPubkey('GET', 'http://localhost/login'), |
||||
$this->createTokenWithEmptyPubkey('GET', 'http://localhost/login'), |
||||
$this->createTokenWithMalformedPubkey('GET', 'http://localhost/login'), |
||||
]; |
||||
|
||||
foreach ($manipulations as $token) { |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $token]); |
||||
|
||||
// Accept either 401 or 500 - both indicate rejection of invalid pubkeys |
||||
$statusCode = $client->getResponse()->getStatusCode(); |
||||
$this->assertTrue( |
||||
in_array($statusCode, [401, 500]), |
||||
"Should reject manipulated pubkeys with 401 or 500, got {$statusCode}" |
||||
); |
||||
|
||||
if ($statusCode === 401) { |
||||
$response = $client->getResponse()->getContent(); |
||||
$this->assertTrue( |
||||
str_contains($response, 'Missing required event fields') || |
||||
str_contains($response, 'Failed to convert public key') || |
||||
str_contains($response, 'Invalid event signature') || |
||||
str_contains($response, 'Signature verification failed'), |
||||
'Should reject manipulated pubkeys with appropriate error message' |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Test rate limiting and DoS protection. |
||||
*/ |
||||
public function testRateLimitingProtection(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
// Send many requests rapidly |
||||
$token = $this->createValidToken('GET', 'http://localhost/login'); |
||||
$successCount = 0; |
||||
|
||||
for ($i = 0; $i < 10; $i++) { |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $token]); |
||||
if ($client->getResponse()->getStatusCode() === 200) { |
||||
$successCount++; |
||||
} |
||||
} |
||||
|
||||
// All should succeed with valid token (no rate limiting on valid requests) |
||||
$this->assertEquals(10, $successCount); |
||||
|
||||
// Test with invalid tokens (should not cause server overload) |
||||
$invalidToken = $this->createTokenWithInvalidSignature('GET', 'http://localhost/login'); |
||||
$start = microtime(true); |
||||
|
||||
for ($i = 0; $i < 5; $i++) { |
||||
$client->request('GET', '/login', [], [], ['HTTP_Authorization' => $invalidToken]); |
||||
} |
||||
|
||||
$duration = microtime(true) - $start; |
||||
|
||||
// Should complete within reasonable time (not hanging due to expensive operations) |
||||
$this->assertLessThan(5.0, $duration, 'Authentication should not be susceptible to DoS via expensive operations'); |
||||
} |
||||
} |
||||
@ -0,0 +1,264 @@
@@ -0,0 +1,264 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Tests\Security; |
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use swentel\nostr\Event\Event; |
||||
use swentel\nostr\Key\Key; |
||||
use swentel\nostr\Sign\Sign; |
||||
|
||||
/** |
||||
* Simplified authentication tests that focus on core functionality. |
||||
*/ |
||||
class NostrAuthenticatorSimpleTest extends WebTestCase |
||||
{ |
||||
private Key $key; |
||||
private string $privateKey; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->key = new Key(); |
||||
$this->privateKey = $this->key->generatePrivateKey(); |
||||
} |
||||
|
||||
public function testValidAuthentication(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
$token = $this->createValidToken('GET', 'http://localhost/login'); |
||||
|
||||
$client->request('GET', '/login', [], [], [ |
||||
'HTTP_Authorization' => $token, |
||||
]); |
||||
|
||||
$response = $client->getResponse(); |
||||
$this->assertSame(200, $response->getStatusCode(), 'Valid authentication should succeed'); |
||||
} |
||||
|
||||
public function testInvalidScheme(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
$client->request('GET', '/login', [], [], [ |
||||
'HTTP_Authorization' => 'Bearer invalid_token', |
||||
]); |
||||
|
||||
$response = $client->getResponse(); |
||||
$this->assertSame(401, $response->getStatusCode(), 'Invalid scheme should return 401'); |
||||
// Check for either the specific error message or the generic unauthenticated message |
||||
$content = $response->getContent(); |
||||
$this->assertTrue( |
||||
str_contains($content, 'Invalid Authorization scheme') || str_contains($content, 'Unauthenticated'), |
||||
'Response should contain either specific error or unauthenticated message' |
||||
); |
||||
} |
||||
|
||||
public function testInvalidBase64(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
$client->request('GET', '/login', [], [], [ |
||||
'HTTP_Authorization' => 'Nostr invalid_base64!@#', |
||||
]); |
||||
|
||||
$response = $client->getResponse(); |
||||
$this->assertSame(401, $response->getStatusCode(), 'Invalid base64 should return 401'); |
||||
$this->assertStringContainsString('Invalid base64 encoding', $response->getContent()); |
||||
} |
||||
|
||||
public function testInvalidJson(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
$client->request('GET', '/login', [], [], [ |
||||
'HTTP_Authorization' => 'Nostr ' . base64_encode('{"invalid": json}'), |
||||
]); |
||||
|
||||
$response = $client->getResponse(); |
||||
$this->assertSame(401, $response->getStatusCode(), 'Invalid JSON should return 401'); |
||||
$this->assertStringContainsString('Invalid JSON', $response->getContent()); |
||||
} |
||||
|
||||
public function testWrongEventKind(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
$token = $this->createTokenWithKind(1, 'GET', 'http://localhost/login'); |
||||
|
||||
$client->request('GET', '/login', [], [], [ |
||||
'HTTP_Authorization' => $token, |
||||
]); |
||||
|
||||
$response = $client->getResponse(); |
||||
$this->assertSame(401, $response->getStatusCode(), 'Wrong event kind should return 401'); |
||||
$this->assertStringContainsString('Invalid event kind', $response->getContent()); |
||||
} |
||||
|
||||
public function testExpiredToken(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
$token = $this->createTokenWithTimestamp('GET', 'http://localhost/login', time() - 120); |
||||
|
||||
$client->request('GET', '/login', [], [], [ |
||||
'HTTP_Authorization' => $token, |
||||
]); |
||||
|
||||
$response = $client->getResponse(); |
||||
$this->assertSame(401, $response->getStatusCode(), 'Expired token should return 401'); |
||||
$this->assertStringContainsString('Authentication event has expired', $response->getContent()); |
||||
} |
||||
|
||||
public function testWrongUrl(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
$token = $this->createValidToken('GET', 'https://wrong.com/login'); |
||||
|
||||
$client->request('GET', '/login', [], [], [ |
||||
'HTTP_Authorization' => $token, |
||||
]); |
||||
|
||||
$response = $client->getResponse(); |
||||
$this->assertSame(401, $response->getStatusCode(), 'Wrong URL should return 401'); |
||||
$this->assertStringContainsString('URL tag does not match request URL', $response->getContent()); |
||||
} |
||||
|
||||
public function testWrongMethod(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
$token = $this->createValidToken('POST', 'http://localhost/login'); |
||||
|
||||
$client->request('GET', '/login', [], [], [ |
||||
'HTTP_Authorization' => $token, |
||||
]); |
||||
|
||||
$response = $client->getResponse(); |
||||
$this->assertSame(401, $response->getStatusCode(), 'Wrong method should return 401'); |
||||
$this->assertStringContainsString('Method tag does not match request method', $response->getContent()); |
||||
} |
||||
|
||||
public function testMissingUrlTag(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
$token = $this->createTokenWithoutTag('u', 'GET', 'http://localhost/login'); |
||||
|
||||
$client->request('GET', '/login', [], [], [ |
||||
'HTTP_Authorization' => $token, |
||||
]); |
||||
|
||||
$response = $client->getResponse(); |
||||
$this->assertSame(401, $response->getStatusCode(), 'Missing URL tag should return 401'); |
||||
// Handle escaped quotes in JSON response |
||||
$content = $response->getContent(); |
||||
$this->assertTrue( |
||||
str_contains($content, 'Missing required "u" (URL) tag') || |
||||
str_contains($content, 'Missing required \\"u\\" (URL) tag'), |
||||
'Response should contain missing URL tag error message' |
||||
); |
||||
} |
||||
|
||||
public function testMissingMethodTag(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
$token = $this->createTokenWithoutTag('method', 'GET', 'http://localhost/login'); |
||||
|
||||
$client->request('GET', '/login', [], [], [ |
||||
'HTTP_Authorization' => $token, |
||||
]); |
||||
|
||||
$response = $client->getResponse(); |
||||
$this->assertSame(401, $response->getStatusCode(), 'Missing method tag should return 401'); |
||||
// Handle escaped quotes in JSON response |
||||
$content = $response->getContent(); |
||||
$this->assertTrue( |
||||
str_contains($content, 'Missing required "method" tag') || |
||||
str_contains($content, 'Missing required \\"method\\" tag'), |
||||
'Response should contain missing method tag error message' |
||||
); |
||||
} |
||||
|
||||
public function testNoAuthHeaderDoesNotInterfere(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
$client->request('GET', '/'); |
||||
$statusCode = $client->getResponse()->getStatusCode(); |
||||
|
||||
// Should NOT be 401 when no Authorization header is provided |
||||
$this->assertNotSame(401, $statusCode, 'Authenticator should not interfere when no auth header is present'); |
||||
} |
||||
|
||||
private function createValidToken(string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->privateKey); |
||||
|
||||
return 'Nostr ' . base64_encode($event->toJson()); |
||||
} |
||||
|
||||
private function createTokenWithKind(int $kind, string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind($kind); |
||||
$event->setCreatedAt(time()); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->privateKey); |
||||
|
||||
return 'Nostr ' . base64_encode($event->toJson()); |
||||
} |
||||
|
||||
private function createTokenWithTimestamp(string $method, string $url, int $timestamp): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt($timestamp); |
||||
$event->setTags([ |
||||
["u", $url], |
||||
["method", $method] |
||||
]); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->privateKey); |
||||
|
||||
return 'Nostr ' . base64_encode($event->toJson()); |
||||
} |
||||
|
||||
private function createTokenWithoutTag(string $tagToRemove, string $method, string $url): string |
||||
{ |
||||
$event = new Event(); |
||||
$event->setContent(''); |
||||
$event->setKind(27235); |
||||
$event->setCreatedAt(time()); |
||||
|
||||
$tags = []; |
||||
if ($tagToRemove !== 'u') { |
||||
$tags[] = ["u", $url]; |
||||
} |
||||
if ($tagToRemove !== 'method') { |
||||
$tags[] = ["method", $method]; |
||||
} |
||||
|
||||
$event->setTags($tags); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $this->privateKey); |
||||
|
||||
return 'Nostr ' . base64_encode($event->toJson()); |
||||
} |
||||
} |
||||
@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Tests\Security; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use App\Security\NostrAuthenticator; |
||||
use App\Entity\Event; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException; |
||||
|
||||
/** |
||||
* Unit tests for NostrAuthenticator focusing on individual methods |
||||
* without requiring a full Symfony application context. |
||||
*/ |
||||
class NostrAuthenticatorUnitTest extends TestCase |
||||
{ |
||||
private NostrAuthenticator $authenticator; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->authenticator = new NostrAuthenticator(); |
||||
} |
||||
|
||||
/** |
||||
* Test that supports() method correctly identifies Nostr authentication requests. |
||||
*/ |
||||
public function testSupportsValidNostrRequest(): void |
||||
{ |
||||
$request = new Request(); |
||||
$request->headers->set('Authorization', 'Nostr eyJpZCI6InRlc3QifQ=='); |
||||
|
||||
$this->assertTrue($this->authenticator->supports($request)); |
||||
} |
||||
|
||||
/** |
||||
* Test that supports() method rejects non-Nostr authentication requests. |
||||
*/ |
||||
public function testSupportsRejectsBearerToken(): void |
||||
{ |
||||
$request = new Request(); |
||||
$request->headers->set('Authorization', 'Bearer token123'); |
||||
|
||||
$this->assertFalse($this->authenticator->supports($request)); |
||||
} |
||||
|
||||
/** |
||||
* Test that supports() method rejects requests without Authorization header. |
||||
*/ |
||||
public function testSupportsRejectsRequestsWithoutAuthHeader(): void |
||||
{ |
||||
$request = new Request(); |
||||
|
||||
$this->assertFalse($this->authenticator->supports($request)); |
||||
} |
||||
|
||||
/** |
||||
* Test authentication with invalid base64 encoding. |
||||
*/ |
||||
public function testAuthenticateWithInvalidBase64(): void |
||||
{ |
||||
$request = new Request(); |
||||
$request->headers->set('Authorization', 'Nostr invalid_base64!@#'); |
||||
|
||||
$this->expectException(AuthenticationException::class); |
||||
$this->expectExceptionMessage('Invalid base64 encoding'); |
||||
|
||||
$this->authenticator->authenticate($request); |
||||
} |
||||
|
||||
/** |
||||
* Test authentication with invalid JSON. |
||||
*/ |
||||
public function testAuthenticateWithInvalidJson(): void |
||||
{ |
||||
$request = new Request(); |
||||
$invalidJson = base64_encode('{"invalid": json}'); |
||||
$request->headers->set('Authorization', 'Nostr ' . $invalidJson); |
||||
|
||||
$this->expectException(AuthenticationException::class); |
||||
$this->expectExceptionMessage('Invalid JSON'); |
||||
|
||||
$this->authenticator->authenticate($request); |
||||
} |
||||
|
||||
/** |
||||
* Test that authentication failure returns proper error response. |
||||
*/ |
||||
public function testOnAuthenticationFailureReturnsJsonError(): void |
||||
{ |
||||
$request = new Request(); |
||||
$exception = new AuthenticationException('Test error message'); |
||||
|
||||
$response = $this->authenticator->onAuthenticationFailure($request, $exception); |
||||
|
||||
$this->assertEquals(401, $response->getStatusCode()); |
||||
$this->assertEquals('application/json', $response->headers->get('Content-Type')); |
||||
|
||||
$content = json_decode($response->getContent(), true); |
||||
$this->assertEquals('Authentication failed', $content['error']); |
||||
$this->assertEquals('Test error message', $content['message']); |
||||
} |
||||
|
||||
/** |
||||
* Test that successful authentication returns null to continue request. |
||||
*/ |
||||
public function testOnAuthenticationSuccessReturnsNull(): void |
||||
{ |
||||
$request = new Request(); |
||||
$token = $this->createMock(\Symfony\Component\Security\Core\Authentication\Token\TokenInterface::class); |
||||
|
||||
$response = $this->authenticator->onAuthenticationSuccess($request, $token, 'main'); |
||||
|
||||
$this->assertNull($response); |
||||
} |
||||
|
||||
/** |
||||
* Test that authenticator is marked as interactive. |
||||
*/ |
||||
public function testIsInteractive(): void |
||||
{ |
||||
$this->assertTrue($this->authenticator->isInteractive()); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue