Browse Source

Security

imwald
Nuša Pukšič 7 months ago
parent
commit
4f4caa3ab9
  1. 51
      src/Security/NostrAuthenticator.php
  2. 18
      src/Security/UserDTOProvider.php
  3. 86
      tests/Security/NostrAuthenticatorTest.php

51
src/Security/NostrAuthenticator.php

@ -5,8 +5,6 @@ namespace App\Security;
use App\Entity\Event; use App\Entity\Event;
use Mdanter\Ecc\Crypto\Signature\SchnorrSignature; use Mdanter\Ecc\Crypto\Signature\SchnorrSignature;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@ -14,18 +12,28 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\Serializer;
/**
* Authenticator for Nostr protocol-based authentication.
*
* This authenticator processes requests to the /login endpoint with a Nostr-based Authorization header.
* It decodes and verifies the Nostr event, checks for expiration, and validates the Schnorr signature.
* On successful authentication, it issues a SelfValidatingPassport with the user's public key in Bech32 format.
*
* Implements interactive authentication for Symfony security.
*/
class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface
{ {
public function __construct( /**
private readonly Security $security * Checks if the request should be handled by this authenticator.
) {} *
* @param Request $request The HTTP request.
* @return bool|null True if the request is supported, false otherwise.
*/
public function supports(Request $request): ?bool public function supports(Request $request): ?bool
{ {
if ($request->getPathInfo() === '/login' && $request->headers->has('Authorization')) { if ($request->getPathInfo() === '/login' && $request->headers->has('Authorization')) {
@ -34,7 +42,14 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut
return false; return false;
} }
public function authenticate(Request $request): Passport /**
* Performs authentication using the Nostr Authorization header.
*
* @param Request $request The HTTP request.
* @return SelfValidatingPassport The authenticated passport.
* @throws AuthenticationException If authentication fails (invalid header, expired, or invalid signature).
*/
public function authenticate(Request $request): SelfValidatingPassport
{ {
$authHeader = $request->headers->get('Authorization'); $authHeader = $request->headers->get('Authorization');
if (!str_starts_with($authHeader, 'Nostr ')) { if (!str_starts_with($authHeader, 'Nostr ')) {
@ -62,16 +77,36 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut
); );
} }
/**
* Handles successful authentication.
*
* @param Request $request The HTTP request.
* @param TokenInterface $token The authenticated token.
* @param string $firewallName The firewall name.
* @return Response|null The response to return, or null to continue.
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{ {
return new Response('Authentication Successful', 200); return new Response('Authentication Successful', 200);
} }
/**
* Handles failed authentication.
*
* @param Request $request The HTTP request.
* @param AuthenticationException $exception The exception thrown during authentication.
* @return Response|null The response to return, or null to continue.
*/
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{ {
return null; return null;
} }
/**
* Indicates whether this authenticator is interactive.
*
* @return bool True if interactive.
*/
public function isInteractive(): bool public function isInteractive(): bool
{ {
return true; return true;

18
src/Security/UserDTOProvider.php

@ -9,6 +9,12 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* Provides user data transfer object (DTO) operations for authentication and user management.
*
* This class is responsible for refreshing user data from the database and cache,
* and for determining if a given class is supported by the provider.
*/
readonly class UserDTOProvider implements UserProviderInterface readonly class UserDTOProvider implements UserProviderInterface
{ {
public function __construct( public function __construct(
@ -20,7 +26,11 @@ readonly class UserDTOProvider implements UserProviderInterface
} }
/** /**
* @inheritDoc * Refreshes the user by reloading it from the database and updating its metadata from cache.
*
* @param UserInterface $user The user to refresh.
* @return UserInterface The refreshed user instance.
* @throws \InvalidArgumentException If the provided user is not an instance of User.
*/ */
public function refreshUser(UserInterface $user): UserInterface public function refreshUser(UserInterface $user): UserInterface
{ {
@ -40,6 +50,12 @@ readonly class UserDTOProvider implements UserProviderInterface
*/ */
public function supportsClass(string $class): bool public function supportsClass(string $class): bool
{ {
/**
* Checks if the provider supports the given user class.
*
* @param string $class The class name to check.
* @return bool True if the class is supported, false otherwise.
*/
return $class === User::class; return $class === User::class;
} }

86
tests/Security/NostrAuthenticatorTest.php

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Kernel;
use swentel\nostr\Event\Event;
use swentel\nostr\Sign\Sign;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
class NostrAuthenticatorTest extends WebTestCase
{
/**
* Tests various authentication scenarios for the Nostr authenticator.
*
* This test sends a GET request to the /login endpoint with different Authorization headers
* and asserts that the response status code and content match the expected values provided
* by the data provider.
*
* @dataProvider provideAuthenticationData
*/
public function testAuthenticationScenarios(string $authorizationHeader, int $expectedStatusCode, string $expectedContent)
{
$client = static::createClient();
$client->request('GET', '/login', [], [], [
'HTTP_Authorization' => $authorizationHeader,
]);
$response = $client->getResponse();
$this->assertSame($expectedStatusCode, $response->getStatusCode());
$this->assertStringContainsString($expectedContent, $response->getContent());
}
/**
* @throws \JsonException
*/
public function provideAuthenticationData(): array
{
// Boot the kernel manually
$kernel = new Kernel('local', true);
$kernel->boot();
$container = $kernel->getContainer();
$nsec = $container->getParameter('nsec');
$note = new Event();
$note->setContent('');
$note->setKind(27235);
$note->setTags([
["u", "https://localhost/login"],
["method", "POST"]
]);
$signer = new Sign();
$signer->signEvent($note, $nsec);
$ser = $note->toJson();
$validToken = 'Nostr ' . base64_encode($ser);
$expiredToken = 'Nostr eyJjcmVhdGVkX2F0IjoxNzMzMzIxMzUyLCJraW5kIjoyNzIzNSwidGFncyI6W1sidSIsImh0dHBzOi8vbG9jYWxob3N0L2xvZ2luIl0sWyJtZXRob2QiLCJHRVQiXV0sImNvbnRlbnQiOiIiLCJwdWJrZXkiOiJkNDc1Y2U0YjM5Nzc1MDcxMzBmNDJjN2Y4NjM0NmVmOTM2ODAwZjNhZTc0ZDVlY2Y4MDg5MjgwY2RjMTkyM2U5IiwiaWQiOiJhYjA4NGM1NWQ5Y2UzMDliN2UxNzIyZGI2ODNjZTc2ZDg5NGNjN2QyYTIzZTRkNWUyMTUyYTM2Y2M2ODI1MTQ5Iiwic2lnIjoiOWI1Yjk2YjhkN2U2ZGM4YWU3ZmM4NjU2ZTE0NDVlZjkwYzc1YWQxNzZkYTRmNmNhMjI0NTRkNTJjNTk3ZTBmNjYwZjAwZjE3MmIxYjMzYzM4YTg2Y2U0YTBiMTdmMDgwMWEyNzJmZmVmYWU0NmY2OTgzZGZjYjRlM2YyZDgwZGYifQ==';
$invalidToken = 'InvalidHeader';
return [
// Scenario: Valid token
'valid_token' => [
'authorizationHeader' => $validToken,
'expectedStatusCode' => Response::HTTP_OK,
'expectedContent' => 'Authentication Successful',
],
// Scenario: Expired token
'expired_token' => [
'authorizationHeader' => $expiredToken,
'expectedStatusCode' => Response::HTTP_UNAUTHORIZED,
'expectedContent' => 'Unauthenticated',
],
// Scenario: Invalid header
'invalid_token' => [
'authorizationHeader' => $invalidToken,
'expectedStatusCode' => Response::HTTP_UNAUTHORIZED,
'expectedContent' => 'Unauthenticated',
]
];
}
}
Loading…
Cancel
Save