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"); }