From f56c78f62603136eb5713a48aa8f56eb14f1e800 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 27 Apr 2025 21:41:53 +0200 Subject: [PATCH] Implement api with ability to read snips and profile --- .gitignore | 2 + .http | 9 +++ config/packages/security.yaml | 2 + http-client.env.json | 5 ++ migrations/Version20250427184240.php | 35 ++++++++++ src/Controller/Api/AbstractApiController.php | 39 +++++++++++ src/Controller/Api/ApiController.php | 43 +++++++++++++ src/Controller/Api/NormalizableInterface.php | 8 +++ src/Controller/UserController.php | 19 +++++- src/Entity/User.php | 15 +++++ src/Security/TokenAuthenticator.php | 68 ++++++++++++++++++++ templates/user/profile.html.twig | 42 ++++++------ 12 files changed, 267 insertions(+), 20 deletions(-) create mode 100644 .http create mode 100644 http-client.env.json create mode 100644 migrations/Version20250427184240.php create mode 100644 src/Controller/Api/AbstractApiController.php create mode 100644 src/Controller/Api/ApiController.php create mode 100644 src/Controller/Api/NormalizableInterface.php create mode 100644 src/Security/TokenAuthenticator.php diff --git a/.gitignore b/.gitignore index c776ddc..334b18f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ /var/ /vendor/ ###< symfony/framework-bundle ### + release.json +http-client.private.env.json \ No newline at end of file diff --git a/.http b/.http new file mode 100644 index 0000000..0f757cf --- /dev/null +++ b/.http @@ -0,0 +1,9 @@ +### api me +GET {{host}}/api/me +Accept: application/json +X-AUTH-TOKEN: {{apiKey}} + +### api snip +GET {{host}}/api/snip/2 +Accept: application/json +X-AUTH-TOKEN: {{apiKey}} diff --git a/config/packages/security.yaml b/config/packages/security.yaml index c2d7c72..6aefdf2 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -24,6 +24,8 @@ security: remember_me: secret: '%kernel.secret%' # required lifetime: 2419200 # 4 weeks in seconds + custom_authenticators: + - App\Security\TokenAuthenticator secured_area: form_login: diff --git a/http-client.env.json b/http-client.env.json new file mode 100644 index 0000000..bedd048 --- /dev/null +++ b/http-client.env.json @@ -0,0 +1,5 @@ +{ + "dev": { + "host": "http://snips.local.loken.nl" + } +} \ No newline at end of file diff --git a/migrations/Version20250427184240.php b/migrations/Version20250427184240.php new file mode 100644 index 0000000..c34aa2e --- /dev/null +++ b/migrations/Version20250427184240.php @@ -0,0 +1,35 @@ +addSql(<<<'SQL' + ALTER TABLE user ADD api_key VARCHAR(255) DEFAULT NULL + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE `user` DROP api_key + SQL); + } +} diff --git a/src/Controller/Api/AbstractApiController.php b/src/Controller/Api/AbstractApiController.php new file mode 100644 index 0000000..823769e --- /dev/null +++ b/src/Controller/Api/AbstractApiController.php @@ -0,0 +1,39 @@ +normalize(); + } + if (is_array($data)) { + foreach ($data as $key => $value) { + if ($value instanceof NormalizableInterface) { + $data[$key] = $value->normalize(); + } + } + } + + return new JsonResponse([ + 'success' => $success, + 'data' => $data, + ]); + } + + final protected function errorResponse(string $message): Response + { + return $this->apiResponse(false, ['message' => $message]); + } + + final protected function successResponse(array|NormalizableInterface $data = []): Response + { + return $this->apiResponse(true, $data); + } +} \ No newline at end of file diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php new file mode 100644 index 0000000..9a36ffe --- /dev/null +++ b/src/Controller/Api/ApiController.php @@ -0,0 +1,43 @@ +getUser(); + + return $this->successResponse([ + 'id' => $user->getId(), + 'name' => $user->getName(), + 'email' => $user->getEmail(), + 'apiKey' => $user->getApiKey(), + ]); + } + + #[Route('/snip/{snip}')] + public function getSnip(Snip $snip, SnipContentService $cs): Response + { + $this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip); + + return $this->successResponse([ + 'id' => $snip->getId(), + 'content' => $cs->getActiveText($snip), + 'createdBy' => [ + 'id' => $snip->getCreatedBy()->getId(), + 'name' => $snip->getCreatedBy()->getName(), + ], + ]); + } +} \ No newline at end of file diff --git a/src/Controller/Api/NormalizableInterface.php b/src/Controller/Api/NormalizableInterface.php new file mode 100644 index 0000000..09f6e2d --- /dev/null +++ b/src/Controller/Api/NormalizableInterface.php @@ -0,0 +1,8 @@ + $lastRelease, ]); } + + #[Route('/apikey/generate', name: '_apikey_generate')] + public function apiKeyGenerate(): Response + { + /** @var User $user */ + $user = $this->getUser(); + + $apiKey = Uuid::v4()->toBase58(); + $user->setApiKey($apiKey); + $this->em->persist($user); + $this->em->flush(); + + $this->addFlash('success', sprintf('Successfully generated new api key: "%s"', $apiKey)); + + return $this->redirectToRoute('user_profile'); + } } \ No newline at end of file diff --git a/src/Entity/User.php b/src/Entity/User.php index 8c7a414..aa7b232 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -34,6 +34,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(length: 255)] private ?string $email = null; + #[ORM\Column(length: 255, nullable: true)] + private ?string $apiKey = null; + public function __toString(): string { return $this->name ?? ''; @@ -132,4 +135,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + + public function getApiKey(): ?string + { + return $this->apiKey; + } + + public function setApiKey(?string $apiKey): static + { + $this->apiKey = $apiKey; + + return $this; + } } \ No newline at end of file diff --git a/src/Security/TokenAuthenticator.php b/src/Security/TokenAuthenticator.php new file mode 100644 index 0000000..36ccb45 --- /dev/null +++ b/src/Security/TokenAuthenticator.php @@ -0,0 +1,68 @@ +headers->has('X-AUTH-TOKEN'); + } + + public function authenticate(Request $request): Passport + { + $apiToken = $request->headers->get('X-AUTH-TOKEN'); + if (empty($apiToken)) { + // The token header was empty, authentication fails with HTTP Status + // Code 401 "Unauthorized" + throw new CustomUserMessageAuthenticationException('No API token provided'); + } + + return new SelfValidatingPassport( + new UserBadge($apiToken, function ($apiToken) { + return $this->em->getRepository(User::class)->findOneBy(['apiKey' => $apiToken]); + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // on success, let the request continue + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $data = [ + // you may want to customize or obfuscate the message first + 'message' => strtr($exception->getMessageKey(), $exception->getMessageData()), + + // or to translate this message + // $this->translator->trans($exception->getMessageKey(), $exception->getMessageData()) + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/templates/user/profile.html.twig b/templates/user/profile.html.twig index a815b4a..f0cfeaa 100644 --- a/templates/user/profile.html.twig +++ b/templates/user/profile.html.twig @@ -1,23 +1,27 @@ {% extends 'base/two.column.html.twig' %} -{% block body %} -
-
-

{{ app.user.name }}

-
- {% if is_granted('ROLE_ADMIN') %} -

-

Latest release stats

- Branch: {{ release.branch }}
- Date: {{ release.date }}
- Hash short: {{ release.commitHashShort }}
- Hash long: {{ release.commitHashLong }}
- Commit date: {{ release.commitDate }}
- {% endif %} -
-
-

Change profile

- {{ form(form) }} -
+{% set title = app.user.name %} + +{% block column1 %} +
+ Api Key + + Regenerate
+
+ {% if is_granted('ROLE_ADMIN') %} +

+

Latest release stats

+ Branch: {{ release.branch }}
+ Date: {{ release.date }}
+ Hash short: {{ release.commitHashShort }}
+ Hash long: {{ release.commitHashLong }}
+ Commit date: {{ release.commitDate }}
+ {% endif %} +{% endblock %} + +{% block column2 %} +

Change profile

+ {{ form(form) }} {% endblock %} \ No newline at end of file