From f546f5242ae4e2d05a2c8a92d6d18367216fac78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kone=C4=8Dn=C3=BD?= Date: Mon, 10 Apr 2023 14:36:03 +0200 Subject: [PATCH] added authentication for api firstly a user has to enable api in settings, then they use basic authentication to obtain a bearer token (valid for 1 hour) with which they can query private/sensitive data tokens can be invalidated prematurely by api itself or from settings (new page api tokens) the only private data that can be accessed with tokens at the moment is tokens themselves (reading and deleting) --- CHANGELOG.md | 1 + app/Api/ApiNotEnabledException.php | 9 ++ app/Api/DI/ApiExtension.php | 4 + app/Api/TokenExpiredException.php | 9 ++ app/Api/TokenNotFoundException.php | 9 ++ app/Api/Tokens.php | 73 ++++++++++++++++ app/Api/Transformers/ApiTokenTransformer.php | 18 ++++ app/Forms/UserSettingsFormFactory.php | 12 ++- app/Model/UserManager.php | 2 +- app/Orm/ApiToken.php | 33 +++++++ app/Orm/ApiTokensMapper.php | 12 +++ app/Orm/ApiTokensRepository.php | 40 +++++++++ app/Orm/Model.php | 1 + app/Orm/User.php | 4 +- app/Presenters/ApiModule/BasePresenter.php | 87 +++++++++++++++++-- .../ApiModule/V1/TokensPresenter.php | 68 +++++++++++++++ app/Presenters/FrontModule/UserPresenter.php | 37 +++++++- .../templates/User/apiTokens.latte | 22 +++++ app/config/main.neon | 1 + migrations/20230408165020_api.php | 20 +++++ migrations/ApiTokenSeeder.php | 35 ++++++++ tests/Nexendrie/Api/TokensTest.phpt | 68 +++++++++++++++ .../Presenters/ApiModule/V1/TApiPresenter.php | 12 ++- .../ApiModule/V1/TokensPresenterTest.phpt | 84 ++++++++++++++++++ .../FrontModule/UserPresenterTest.phpt | 6 ++ 25 files changed, 651 insertions(+), 16 deletions(-) create mode 100644 app/Api/ApiNotEnabledException.php create mode 100644 app/Api/TokenExpiredException.php create mode 100644 app/Api/TokenNotFoundException.php create mode 100644 app/Api/Tokens.php create mode 100644 app/Api/Transformers/ApiTokenTransformer.php create mode 100644 app/Orm/ApiToken.php create mode 100644 app/Orm/ApiTokensMapper.php create mode 100644 app/Orm/ApiTokensRepository.php create mode 100644 app/Presenters/ApiModule/V1/TokensPresenter.php create mode 100644 app/Presenters/FrontModule/templates/User/apiTokens.latte create mode 100644 migrations/20230408165020_api.php create mode 100644 migrations/ApiTokenSeeder.php create mode 100644 tests/Nexendrie/Api/TokensTest.phpt create mode 100644 tests/Nexendrie/Presenters/ApiModule/V1/TokensPresenterTest.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a07393..491b03d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Version 1.0.0-beta9+dev - added pagination to homepage and articles' categories - added notifications - merged search types articles title and articles text +- added authentication for api Version 1.0.0-beta9 - removed user setting infomails diff --git a/app/Api/ApiNotEnabledException.php b/app/Api/ApiNotEnabledException.php new file mode 100644 index 00000000..88c11aaf --- /dev/null +++ b/app/Api/ApiNotEnabledException.php @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/app/Api/DI/ApiExtension.php b/app/Api/DI/ApiExtension.php index eb4ce398..9b1c1f33 100644 --- a/app/Api/DI/ApiExtension.php +++ b/app/Api/DI/ApiExtension.php @@ -16,6 +16,8 @@ public function getConfigSchema(): \Nette\Schema\Schema { return Expect::structure([ "transformers" => Expect::arrayOf("class")->default([]), "maxDepth" => Expect::int(2), + "tokenTtl" => Expect::int(60 * 60), + "tokenLength" => Expect::int(20), ]); } @@ -24,6 +26,8 @@ public function loadConfiguration(): void { $config = $this->getConfig(); $builder->addDefinition($this->prefix("entityConverter")) ->setFactory(\Nexendrie\Api\EntityConverter::class, [$config->maxDepth]); + $builder->addDefinition($this->prefix("tokens")) + ->setFactory(\Nexendrie\Api\Tokens::class, [$config->tokenTtl, $config->tokenLength,]); foreach($config->transformers as $index => $transformer) { $builder->addDefinition($this->prefix("transformer.$index")) ->setType($transformer); diff --git a/app/Api/TokenExpiredException.php b/app/Api/TokenExpiredException.php new file mode 100644 index 00000000..390ad8ed --- /dev/null +++ b/app/Api/TokenExpiredException.php @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/app/Api/TokenNotFoundException.php b/app/Api/TokenNotFoundException.php new file mode 100644 index 00000000..c5191b2e --- /dev/null +++ b/app/Api/TokenNotFoundException.php @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/app/Api/Tokens.php b/app/Api/Tokens.php new file mode 100644 index 00000000..ce789768 --- /dev/null +++ b/app/Api/Tokens.php @@ -0,0 +1,73 @@ +ttl = $ttl; + $this->length = $length; + $this->orm = $orm; + $this->user = $user; + } + + public function create(): ApiToken { + if(!$this->user->isLoggedIn()) { + throw new AuthenticationNeededException(); + } + + /** @var \Nexendrie\Orm\User $user */ + $user = $this->orm->users->getById($this->user->id); + if(!$user->api) { + throw new ApiNotEnabledException(); + } + + $token = new ApiToken(); + $token->user = $user; + $token->token = Random::generate(max(1, $this->length)); + $token->expire = time() + $this->ttl; + $this->orm->apiTokens->persistAndFlush($token); + + return $token; + } + + public function invalidate(string $token): void { + if(!$this->user->isLoggedIn()) { + throw new AuthenticationNeededException(); + } + + $tokenEntity = $this->orm->apiTokens->getByToken($token); + if($tokenEntity === null || $tokenEntity->user->id !== $this->user->id) { + throw new TokenNotFoundException(); + } + + $dt = new \DateTime(); + if($tokenEntity->expire <= $dt->getTimestamp()) { + throw new TokenExpiredException(); + } + + $tokenEntity->expire = $dt->getTimestamp(); + $this->orm->apiTokens->persistAndFlush($tokenEntity); + } + + public function getLength(): int { + return $this->length; + } +} +?> \ No newline at end of file diff --git a/app/Api/Transformers/ApiTokenTransformer.php b/app/Api/Transformers/ApiTokenTransformer.php new file mode 100644 index 00000000..d10ce9c3 --- /dev/null +++ b/app/Api/Transformers/ApiTokenTransformer.php @@ -0,0 +1,18 @@ + "created", "expireAt" => "expire",]; + + public function getEntityClassName(): string { + return \Nexendrie\Orm\ApiToken::class; + } + + public function getCollectionName(): string { + return "tokens"; + } +} +?> \ No newline at end of file diff --git a/app/Forms/UserSettingsFormFactory.php b/app/Forms/UserSettingsFormFactory.php index a3d5460f..ab3094d3 100644 --- a/app/Forms/UserSettingsFormFactory.php +++ b/app/Forms/UserSettingsFormFactory.php @@ -3,7 +3,9 @@ namespace Nexendrie\Forms; +use Nette\Application\LinkGenerator; use Nette\Application\UI\Form; +use Nette\Utils\Html; use Nexendrie\Model\ThemesManager; use Nexendrie\Model\UserManager; use Nette\Security\User; @@ -19,13 +21,15 @@ final class UserSettingsFormFactory { protected UserManager $model; protected User $user; protected ThemesManager $themesManager; + protected LinkGenerator $linkGenerator; - public function __construct(UserManager $model, User $user, ThemesManager $themesManager) { + public function __construct(UserManager $model, User $user, ThemesManager $themesManager, LinkGenerator $linkGenerator) { $this->model = $model; $this->user = $user; $this->themesManager = $themesManager; + $this->linkGenerator = $linkGenerator; } - + public function create(): Form { $defaultValues = $this->model->getSettings(); $form = new Form(); @@ -57,6 +61,10 @@ public function create(): Form { ->setHtmlId("notifications_browser") ->setHtmlAttribute("onclick", "notificationsSetup()"); } + $apiTokensLink = $this->linkGenerator->link("Front:User:apiTokens"); + $form->addGroup("API") + ->setOption("description", Html::el("p")->setHtml("Povolí používání API pro tento účet. Svoje platné tokeny můžeš spravovat zde.")); + $form->addCheckbox("api", "Zapnout API pro tento účet"); $form->setCurrentGroup(null); $form->addSubmit("save", "Uložit změny"); $form->setDefaults($defaultValues); diff --git a/app/Model/UserManager.php b/app/Model/UserManager.php index e118d016..9a014a7e 100644 --- a/app/Model/UserManager.php +++ b/app/Model/UserManager.php @@ -110,7 +110,7 @@ public function getSettings(): array { $user = $this->orm->users->getById($this->user->id); $settings = [ "publicname" => $user->publicname, "email" => $user->email, "style" => $user->style, "gender" => $user->gender, - "notifications" => $user->notifications, + "notifications" => $user->notifications, "api" => $user->api, ]; return $settings; } diff --git a/app/Orm/ApiToken.php b/app/Orm/ApiToken.php new file mode 100644 index 00000000..480d3f41 --- /dev/null +++ b/app/Orm/ApiToken.php @@ -0,0 +1,33 @@ +localeModel = $localeModel; + } + + protected function getterExpireAt(): string { + return $this->localeModel->formatDateTime($this->expire); + } + + protected function getterCreatedAt(): string { + return $this->localeModel->formatDateTime($this->created); + } +} +?> \ No newline at end of file diff --git a/app/Orm/ApiTokensMapper.php b/app/Orm/ApiTokensMapper.php new file mode 100644 index 00000000..5e4b038e --- /dev/null +++ b/app/Orm/ApiTokensMapper.php @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/app/Orm/ApiTokensRepository.php b/app/Orm/ApiTokensRepository.php new file mode 100644 index 00000000..f990f858 --- /dev/null +++ b/app/Orm/ApiTokensRepository.php @@ -0,0 +1,40 @@ +findBy(["user" => $user]); + } + + public function getByToken(string $token): ?ApiToken { + return $this->getBy(["token" => $token]); + } + + /** + * @param User|int $user + * @return ICollection|ApiToken[] + */ + public function findActiveForUser($user): ICollection { + return $this->findBy(["user" => $user, "expire>=" => time(),]); + } +} +?> \ No newline at end of file diff --git a/app/Orm/Model.php b/app/Orm/Model.php index 0d1306c4..2d1059d0 100644 --- a/app/Orm/Model.php +++ b/app/Orm/Model.php @@ -52,6 +52,7 @@ * @property-read ChatMessagesRepository $chatMessages * @property-read ContentReportsRepository $contentReports * @property-read NotificationsRepository $notifications + * @property-read ApiTokensRepository $apiTokens */ final class Model extends \Nextras\Orm\Model\Model { diff --git a/app/Orm/User.php b/app/Orm/User.php index d2da3f82..652c4621 100644 --- a/app/Orm/User.php +++ b/app/Orm/User.php @@ -41,6 +41,7 @@ * @property Order|null $order {m:1 Order::$members} {default null} * @property OrderRank|null $orderRank {m:1 OrderRank::$people} {default null} * @property bool $notifications {default false} + * @property bool $api {default false} * @property-read float $adventureBonusIncome {virtual} * @property OneHasMany|Comment[] $comments {1:m Comment::$author} * @property OneHasMany|Article[] $articles {1:m Article::$author} @@ -68,7 +69,8 @@ * @property OneHasMany|GuildFee[] $guildFees {1:m GuildFee::$user} * @property OneHasMany|OrderFee[] $orderFees {1:m OrderFee::$user} * @property OneHasMany|ChatMessage[] $chatMessages {1:m ChatMessage::$user} - * @property OneHasMany|Notification $notificationQueue {1:m Notification::$user} + * @property OneHasMany|Notification[] $notificationQueue {1:m Notification::$user} + * @property OneHasMany|ApiToken[] $apiTokens {1:m ApiToken::$user} * @property-read string $title {virtual} * @property-read int $completedAdventures {virtual} * @property-read int $completedJobs {virtual} diff --git a/app/Presenters/ApiModule/BasePresenter.php b/app/Presenters/ApiModule/BasePresenter.php index b5ac652c..09fa6ed0 100644 --- a/app/Presenters/ApiModule/BasePresenter.php +++ b/app/Presenters/ApiModule/BasePresenter.php @@ -8,6 +8,8 @@ use Nette\Http\IResponse; use Nette\Utils\Json; use Nette\Utils\Strings; +use Nexendrie\Api\Tokens; +use Nexendrie\Model\Authenticator; use Nextras\Orm\Entity\Entity; /** @@ -18,18 +20,22 @@ abstract class BasePresenter extends \Nette\Application\UI\Presenter { protected \Nexendrie\Orm\Model $orm; protected \Nexendrie\Api\EntityConverter $entityConverter; + protected Tokens $tokens; + protected Authenticator $authenticator; protected bool $cachingEnabled = true; protected bool $publicCache = true; - public function __construct(\Nexendrie\Orm\Model $orm, \Nexendrie\Api\EntityConverter $entityConverter) { + public function __construct(\Nexendrie\Orm\Model $orm, \Nexendrie\Api\EntityConverter $entityConverter, Tokens $tokens, Authenticator $authenticator) { parent::__construct(); $this->orm = $orm; $this->entityConverter = $entityConverter; + $this->tokens = $tokens; + $this->authenticator = $authenticator; } /** * If no response was sent, we received a request that we are not able to handle. - * That means e. g. invalid associations. + * That means e.g. invalid associations. */ protected function beforeRender(): void { $this->getHttpResponse()->setCode(IResponse::S400_BAD_REQUEST); @@ -40,6 +46,7 @@ protected function shutdown(\Nette\Application\Response $response): void { parent::shutdown($response); // do not send cookies with response, they are not (meant to be) used for authentication $this->getHttpResponse()->deleteHeader("Set-Cookie"); + $this->user->logout(true); } /** @@ -69,12 +76,14 @@ protected function getAllowedMethods(): array { /** * A quick way to send 404 status with an appropriate message to the client. - * It is meant to be used only in @see sendEntity method - * or @see actionReadAll method when the associated resource was not found. + * It is meant to be used only in {@see sendEntity}, {@see actionReadAll} or {@see actionDelete} + * method when the associated resource was not found. */ protected function resourceNotFound(string $resource, int $id): void { $this->getHttpResponse()->setCode(IResponse::S404_NOT_FOUND); - $this->sendJson(["message" => Strings::firstUpper($resource) . " with id $id was not found."]); + $payload = ["message" => Strings::firstUpper($resource) . " with id $id was not found."]; + $this->addContentLengthHeader($payload); + $this->sendJson($payload); } /** @@ -153,7 +162,7 @@ protected function getCollectionModifiedTime(iterable $collection): int { /** * A quick way to send a collection of entities as response. - * It is meant to be used in @see actionReadAll method. + * It is meant to be used in {@see actionReadAll} method. */ protected function sendCollection(iterable $collection): void { $data = $this->entityConverter->convertCollection($collection, $this->getApiVersion()); @@ -186,7 +195,7 @@ protected function getEntityModifiedTime(Entity $entity): int { /** * A quick way to send single entity as response. - * It is meant to be used in @see actionRead method. + * It is meant to be used in {@see actionRead} method. */ protected function sendEntity(?Entity $entity): void { if($entity === null) { @@ -194,7 +203,7 @@ protected function sendEntity(?Entity $entity): void { } $data = $this->entityConverter->convertEntity($entity, $this->getApiVersion()); $links = $data->_links ?? []; - foreach($links as $rel => $link) { + foreach($links as $link) { $this->getHttpResponse()->addHeader("Link", $this->createLinkHeader($link->rel, $link->href)); } $payload = [$this->getEntityName() => $data]; @@ -203,6 +212,37 @@ protected function sendEntity(?Entity $entity): void { $this->sendJson($payload); } + /** + * Send the entity that was created in {@see actionCreate} method. + * Sets appropriate status code and Location header. + */ + protected function sendCreatedEntity(Entity $entity): void { + $this->getHttpResponse()->setCode(IResponse::S201_CREATED); + $data = $this->entityConverter->convertEntity($entity, $this->getApiVersion()); + $links = $data->_links ?? []; + foreach($links as $link) { + $this->getHttpResponse()->addHeader("Link", $this->createLinkHeader($link->rel, $link->href)); + if($link->rel === "self") { + $this->getHttpResponse()->setHeader("Location", $link->href); + } + } + $payload = [$this->getEntityName() => $data]; + $this->addContentLengthHeader($payload); + $this->sendJson($payload); + } + + /** + * A quick way to send info about a deleted entity as response. Handles non-existing entity. + * It is meant to be used in {@see actionDelete} method. + */ + protected function sendDeletedEntity(?Entity $entity) : void { + if($entity === null) { + $this->resourceNotFound($this->getInvalidEntityName(), $this->getId()); + } + $this->getHttpResponse()->setCode(IResponse::S204_NO_CONTENT); + $this->sendJson(["message" => Strings::firstUpper($this->getEntityName()) . " with id {$this->getId()} was deleted."]); + } + protected function addContentLengthHeader(array $payload): void { $this->getHttpResponse()->addHeader("Content-Length", (string) strlen(Json::encode($payload))); } @@ -229,5 +269,36 @@ protected function getSelfLink(): string { $url = $this->getHttpRequest()->getUrl(); return $url->hostUrl . $url->path; } + + protected function tryLogin(): void { + preg_match('#^Bearer (.+)$#', $this->getHttpRequest()->getHeader('authorization') ?? '', $matches); + if(!is_array($matches) || !isset($matches[1])) { + return; + } + $token = $this->orm->apiTokens->getByToken($matches[1]); + if($token === null || $token->expire <= time()) { + $this->getHttpResponse()->setCode(IResponse::S401_UNAUTHORIZED); + $this->sendJson(["message" => "Provided token is not valid."]); + } + $this->user->login($this->authenticator->getIdentity($token->user)); + } + + protected function requiresLogin(): void { + $this->tryLogin(); + if(!$this->user->isLoggedIn()) { + $this->getHttpResponse()->setCode(IResponse::S401_UNAUTHORIZED); + $this->sendJson(["message" => "This action requires authentication."]); + } + } + + protected function getBasicCredentials(): array { + return [$_SERVER["PHP_AUTH_USER"] ?? "", $_SERVER["PHP_AUTH_PW"] ?? "",]; + } + + protected function sendBasicAuthRequest(string $message = "This action requires authentication."): void { + $this->getHttpResponse()->setHeader("WWW-Authenticate", "Basic realm=\"Nexendrie API\""); + $this->getHttpResponse()->setCode(IResponse::S401_UNAUTHORIZED); + $this->sendJson(["message" => $message]); + } } ?> \ No newline at end of file diff --git a/app/Presenters/ApiModule/V1/TokensPresenter.php b/app/Presenters/ApiModule/V1/TokensPresenter.php new file mode 100644 index 00000000..84363b27 --- /dev/null +++ b/app/Presenters/ApiModule/V1/TokensPresenter.php @@ -0,0 +1,68 @@ +requiresLogin(); + if(isset($this->params["associations"]) && count($this->params["associations"]) > 0) { + return; + } + $records = $this->orm->apiTokens->findActiveForUser($this->user->id); + $this->sendCollection($records); + } + + public function actionRead(): void { + $this->requiresLogin(); + $record = $this->orm->apiTokens->getById($this->getId()); + if($record !== null) { + if($record->user->id !== $this->user->id || $record->expire <= time()) { + $record = null; + } + } + $this->sendEntity($record); + } + + public function actionCreate(): void { + try { + $identity = $this->authenticator->authenticate($this->getBasicCredentials()); + $this->user->login($identity); + } catch(AuthenticationException $e) { + $this->sendBasicAuthRequest($e->getMessage()); + } catch(\Throwable $e) { + $this->sendBasicAuthRequest(); + } + try { + $token = $this->tokens->create(); + } catch(ApiNotEnabledException $e) { + $this->getHttpResponse()->setCode(IResponse::S403_FORBIDDEN); + $this->sendJson(["message" => "You do not have API enabled."]); + } + $this->sendCreatedEntity($token); + } + + public function actionDelete(): void { + $this->requiresLogin(); + $record = $this->orm->apiTokens->getById($this->getId()); + if($record !== null) { + if($record->user->id !== $this->user->id || $record->expire <= time()) { + $record = null; + } + } + if($record !== null) { + $this->tokens->invalidate($record->token); + } + $this->sendDeletedEntity($record); + } +} +?> \ No newline at end of file diff --git a/app/Presenters/FrontModule/UserPresenter.php b/app/Presenters/FrontModule/UserPresenter.php index 742e3e52..2924f5ec 100644 --- a/app/Presenters/FrontModule/UserPresenter.php +++ b/app/Presenters/FrontModule/UserPresenter.php @@ -4,6 +4,10 @@ namespace Nexendrie\Presenters\FrontModule; use Nette\Application\UI\Form; +use Nexendrie\Api\ApiNotEnabledException; +use Nexendrie\Api\TokenExpiredException; +use Nexendrie\Api\TokenNotFoundException; +use Nexendrie\Api\Tokens; use Nexendrie\Forms\LoginFormFactory; use Nexendrie\Forms\RegisterFormFactory; use Nexendrie\Forms\UserSettingsFormFactory; @@ -19,15 +23,17 @@ final class UserPresenter extends BasePresenter { protected \Nexendrie\Model\Authenticator $model; protected \Nexendrie\Model\Locale $localeModel; protected ORM $orm; + private Tokens $apiTokens; /** @persistent */ public string $backlink = ""; protected bool $cachingEnabled = false; - public function __construct(\Nexendrie\Model\Authenticator $model, \Nexendrie\Model\Locale $localeModel, ORM $orm) { + public function __construct(\Nexendrie\Model\Authenticator $model, \Nexendrie\Model\Locale $localeModel, ORM $orm, Tokens $apiTokens) { parent::__construct(); $this->model = $model; $this->localeModel = $localeModel; $this->orm = $orm; + $this->apiTokens = $apiTokens; } /** @@ -101,5 +107,34 @@ public function renderList(): void { ->orderBy("group->level", ICollection::DESC) ->orderBy("created"); } + + public function renderApiTokens(): void { + $this->requiresLogin(); + $this->template->tokens = $this->orm->apiTokens->findActiveForUser($this->user->id); + } + + public function handleCreateApiToken(): void { + $this->requiresLogin(); + try { + $this->apiTokens->create(); + $this->flashMessage("API token úspěšně vytvořen", "success"); + } catch(ApiNotEnabledException $e) { + $this->flashMessage("Nemáš povolené API.", "error"); + } + $this->redirect("apiTokens"); + } + + public function handleInvalidateApiToken(string $token): void { + $this->requiresLogin(); + try { + $this->apiTokens->invalidate($token); + $this->flashMessage("Token zneplatněn.", "success"); + } catch(TokenNotFoundException $e) { + $this->flashMessage("Token nenalezen.", "error"); + } catch(TokenExpiredException $e) { + $this->flashMessage("Token už vypršel.", "warning"); + } + $this->redirect("apiTokens"); + } } ?> \ No newline at end of file diff --git a/app/Presenters/FrontModule/templates/User/apiTokens.latte b/app/Presenters/FrontModule/templates/User/apiTokens.latte new file mode 100644 index 00000000..b1eb77af --- /dev/null +++ b/app/Presenters/FrontModule/templates/User/apiTokens.latte @@ -0,0 +1,22 @@ +{layout "../@layout.latte"} +{block title}API tokeny{/block} +{block content} +

API tokeny

+

+ Vytvořit nový token +

+ + + + + + + + + + + + + +
TokenVytvořenVypršíAkce
{$token->token}{$token->createdAt}{$token->expireAt}Zneplatnit
+{/block} diff --git a/app/config/main.neon b/app/config/main.neon index c09d5ddb..cd7f9cc8 100644 --- a/app/config/main.neon +++ b/app/config/main.neon @@ -45,6 +45,7 @@ nexendrie.api: transformers: - Nexendrie\Api\Transformers\AdventureNpcTransformer - Nexendrie\Api\Transformers\AdventureTransformer + - Nexendrie\Api\Transformers\ApiTokenTransformer - Nexendrie\Api\Transformers\ArticleTransformer - Nexendrie\Api\Transformers\CastleTransformer - Nexendrie\Api\Transformers\CommentTransformer diff --git a/migrations/20230408165020_api.php b/migrations/20230408165020_api.php new file mode 100644 index 00000000..5267b514 --- /dev/null +++ b/migrations/20230408165020_api.php @@ -0,0 +1,20 @@ +table("users") + ->addColumn("api", "boolean", ["default" => false,]) + ->update(); + $this->table("api_tokens") + ->addColumn("token", "text") + ->addColumn("user", "integer") + ->addColumn("expire", "integer") + ->addColumn("created", "integer") + ->addForeignKey("user", "users") + ->create(); + } +} +?> \ No newline at end of file diff --git a/migrations/ApiTokenSeeder.php b/migrations/ApiTokenSeeder.php new file mode 100644 index 00000000..92306df1 --- /dev/null +++ b/migrations/ApiTokenSeeder.php @@ -0,0 +1,35 @@ +table("api_tokens") + ->insert([ + [ + 'id' => 1, + 'token' => 'test1', + 'user' => 1, + 'created' => time(), + 'expire' => time() + 3600, + ], + [ + 'id' => 2, + 'token' => 'test2', + 'user' => 1, + 'created' => time(), + 'expire' => time() + 3600, + ], + [ + 'id' => 3, + 'token' => 'test3', + 'user' => 2, + 'created' => time(), + 'expire' => time() + 3600, + ], + ]) + ->update(); + } +} +?> \ No newline at end of file diff --git a/tests/Nexendrie/Api/TokensTest.phpt b/tests/Nexendrie/Api/TokensTest.phpt new file mode 100644 index 00000000..f988083f --- /dev/null +++ b/tests/Nexendrie/Api/TokensTest.phpt @@ -0,0 +1,68 @@ +model = $this->getService(Tokens::class); + $this->orm = $this->getService(\Nexendrie\Orm\Model::class); + } + + public function testCreate() { + Assert::exception(function() { + $this->model->create(); + }, AuthenticationNeededException::class); + $this->login(); + Assert::exception(function() { + $this->model->create(); + }, ApiNotEnabledException::class); + $this->modifyUser(["api" => true,], function() { + $token = $this->model->create(); + Assert::type(ApiToken::class, $token); + Assert::true(strlen($token->token) === $this->model->length); + Assert::true($token->created <= time()); + Assert::true($token->expire > time()); + }); + } + + public function testInvalidate() { + Assert::exception(function() { + $this->model->invalidate("abc"); + }, AuthenticationNeededException::class); + $this->login(); + Assert::exception(function() { + $this->model->invalidate("abc"); + }, TokenNotFoundException::class); + $token = null; + $this->modifyUser(["api" => true,], function() use (&$token) { + $token = $this->model->create()->token; + }); + Assert::noError(function() use ($token) { + $this->model->invalidate($token); + }); + Assert::exception(function() use ($token) { + $this->model->invalidate($token); + }, TokenExpiredException::class); + $this->login("Rahym"); + Assert::exception(function() use ($token) { + $this->model->invalidate($token); + }, TokenNotFoundException::class); + } +} + +$test = new TokensTest(); +$test->run(); +?> \ No newline at end of file diff --git a/tests/Nexendrie/Presenters/ApiModule/V1/TApiPresenter.php b/tests/Nexendrie/Presenters/ApiModule/V1/TApiPresenter.php index 4ff50d46..0e1866cb 100644 --- a/tests/Nexendrie/Presenters/ApiModule/V1/TApiPresenter.php +++ b/tests/Nexendrie/Presenters/ApiModule/V1/TApiPresenter.php @@ -22,9 +22,10 @@ trait TApiPresenter { /** * @var string[] METHOD => action */ - protected $forbiddenMethods = [ + protected array $forbiddenMethods = [ "POST" => "create", "PUT" => "update", "PATCH" => "partialUpdate", "DELETE" => "delete", ]; + protected bool $readAllRequiresLogin = false; protected function getPresenterName(): string { $presenter = Strings::before(static::class, "PresenterTest"); @@ -52,8 +53,13 @@ public function testForbiddenActions() { public function testInvalidAssociations() { $presenter = $this->getPresenterName(); - $expected = ["message" => "This action is not allowed."]; - $this->checkJsonScheme("$presenter:readAll", $expected, ["associations" => ["abc" => 1]]); + $expectedNotAllowed = ["message" => "This action is not allowed."]; + if($this->readAllRequiresLogin) { + $expectedNotLoggedIn = ["message" => "This action requires authentication."]; + $this->checkJsonScheme("$presenter:readAll", $expectedNotLoggedIn, ["associations" => ["abc" => 1]]); + $this->login(); + } + $this->checkJsonScheme("$presenter:readAll", $expectedNotAllowed, ["associations" => ["abc" => 1]]); } public function testOptions() { diff --git a/tests/Nexendrie/Presenters/ApiModule/V1/TokensPresenterTest.phpt b/tests/Nexendrie/Presenters/ApiModule/V1/TokensPresenterTest.phpt new file mode 100644 index 00000000..806179b5 --- /dev/null +++ b/tests/Nexendrie/Presenters/ApiModule/V1/TokensPresenterTest.phpt @@ -0,0 +1,84 @@ +forbiddenMethods = ["PUT" => "update", "PATCH" => "partialUpdate",]; + $this->readAllRequiresLogin = true; + } + + private function checkTokens(\Nette\Application\Responses\JsonResponse $response, int $count): void { + $json = $response->getPayload(); + Assert::type("array", $json["tokens"]); + Assert::count($count, $json["tokens"]); + foreach($json["tokens"] as $token) { + Assert::type(\stdClass::class, $token); + Assert::type("int", $token->id); + Assert::type("string", $token->token); + Assert::type("string", $token->created); + Assert::type("string", $token->expire); + } + } + + public function testReadAll() { + $action = $this->getPresenterName() . ":readAll"; + $expected = ["message" => "This action requires authentication."]; + $this->checkJsonScheme($action, $expected); + $this->login(); + $response = $this->checkJson($action); + $this->checkTokens($response, 2); + $this->login("Rahym"); + $response = $this->checkJson($action); + $this->checkTokens($response, 1); + } + + public function testRead() { + $action = $this->getPresenterName() . ":read"; + $expected = ["message" => "This action requires authentication."]; + $this->checkJsonScheme($action, $expected, ["id" => 1]); + $this->login(); + $response = $this->checkJson($action, ["id" => 1]); + $json = $response->getPayload(); + Assert::type(\stdClass::class, $json["token"]); + Assert::same("test1", $json["token"]->token); + $this->login(); + $expected = ["message" => "Token with id 50 was not found."]; + $this->checkJsonScheme($action, $expected, ["id" => 50]); + $this->login("Rahym"); + $expected = ["message" => "Token with id 1 was not found."]; + $this->checkJsonScheme($action, $expected, ["id" => 1]); + } + + public function testCreate() { + $action = $this->getPresenterName() . ":create"; + $expected = ["message" => "E-mail not found."]; + $this->checkJsonScheme($action, $expected); + $_SERVER["PHP_AUTH_USER"] = "jakub.konecny2@centrum.cz"; + $expected = ["message" => "Invalid password."]; + $this->checkJsonScheme($action, $expected); + } + + public function testDelete() { + $action = $this->getPresenterName() . ":delete"; + $expected = ["message" => "This action requires authentication."]; + $this->checkJsonScheme($action, $expected, ["id" => 2]); + $this->login(); + $expected = ["message" => "Token with id 2 was deleted."]; + $this->checkJsonScheme($action, $expected, ["id" => 2]); + $this->login("Rahym"); + $expected = ["message" => "Token with id 1 was not found."]; + $this->checkJsonScheme($action, $expected, ["id" => 1]); + } +} + +$test = new TokensPresenterTest(); +$test->run(); +?> \ No newline at end of file diff --git a/tests/Nexendrie/Presenters/FrontModule/UserPresenterTest.phpt b/tests/Nexendrie/Presenters/FrontModule/UserPresenterTest.phpt index da1802a4..3102dc7d 100644 --- a/tests/Nexendrie/Presenters/FrontModule/UserPresenterTest.phpt +++ b/tests/Nexendrie/Presenters/FrontModule/UserPresenterTest.phpt @@ -39,6 +39,12 @@ final class UserPresenterTest extends \Tester\TestCase { $this->checkAction(":Front:User:settings"); } + public function testApiTokens() { + $this->checkRedirect(":Front:User:apiTokens", "/user/login"); + $this->login(); + $this->checkAction(":Front:User:apiTokens"); + } + public function testList() { $this->checkAction(":Front:User:list"); }