From 4f4caa3ab95173d37b520da2ee322edb504ad01f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Tue, 10 Jun 2025 19:17:18 +0200 Subject: [PATCH] Security --- src/Security/NostrAuthenticator.php | 51 +++++++++++--- src/Security/UserDTOProvider.php | 18 ++++- tests/Security/NostrAuthenticatorTest.php | 86 +++++++++++++++++++++++ 3 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 tests/Security/NostrAuthenticatorTest.php diff --git a/src/Security/NostrAuthenticator.php b/src/Security/NostrAuthenticator.php index 550a488..795a72f 100644 --- a/src/Security/NostrAuthenticator.php +++ b/src/Security/NostrAuthenticator.php @@ -5,8 +5,6 @@ namespace App\Security; use App\Entity\Event; use Mdanter\Ecc\Crypto\Signature\SchnorrSignature; use swentel\nostr\Key\Key; -use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; 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\InteractiveAuthenticatorInterface; 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\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; 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 { - 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 { if ($request->getPathInfo() === '/login' && $request->headers->has('Authorization')) { @@ -34,7 +42,14 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut 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'); 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 { 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 { return null; } + /** + * Indicates whether this authenticator is interactive. + * + * @return bool True if interactive. + */ public function isInteractive(): bool { return true; diff --git a/src/Security/UserDTOProvider.php b/src/Security/UserDTOProvider.php index 0e181c4..5bf42d0 100644 --- a/src/Security/UserDTOProvider.php +++ b/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\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 { 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 { @@ -40,6 +50,12 @@ readonly class UserDTOProvider implements UserProviderInterface */ 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; } diff --git a/tests/Security/NostrAuthenticatorTest.php b/tests/Security/NostrAuthenticatorTest.php new file mode 100644 index 0000000..4caa491 --- /dev/null +++ b/tests/Security/NostrAuthenticatorTest.php @@ -0,0 +1,86 @@ +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', + ] + ]; + } +}