From 0619662c4843265b2db1b2c3637519dce725abbd Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Sat, 17 Feb 2024 13:24:30 +0100 Subject: [PATCH] feat: AccessTokenResource --- framework/core/src/Api/ApiServiceProvider.php | 1 + .../CreateAccessTokenController.php | 60 -------- .../DeleteAccessTokenController.php | 46 ------- .../Controller/ListAccessTokensController.php | 53 ------- framework/core/src/Api/Endpoint/Delete.php | 4 +- .../Api/Resource/AbstractDatabaseResource.php | 16 ++- .../src/Api/Resource/AccessTokenResource.php | 129 ++++++++++++++++++ .../src/Api/Resource/Contracts/Deletable.php | 3 +- framework/core/src/Api/routes.php | 21 --- framework/core/src/Http/AccessToken.php | 9 +- .../api/access_tokens/CreateTest.php | 12 +- 11 files changed, 164 insertions(+), 190 deletions(-) delete mode 100644 framework/core/src/Api/Controller/CreateAccessTokenController.php delete mode 100644 framework/core/src/Api/Controller/DeleteAccessTokenController.php delete mode 100644 framework/core/src/Api/Controller/ListAccessTokensController.php create mode 100644 framework/core/src/Api/Resource/AccessTokenResource.php diff --git a/framework/core/src/Api/ApiServiceProvider.php b/framework/core/src/Api/ApiServiceProvider.php index 153c8cf2f..69646ad4a 100644 --- a/framework/core/src/Api/ApiServiceProvider.php +++ b/framework/core/src/Api/ApiServiceProvider.php @@ -40,6 +40,7 @@ class ApiServiceProvider extends AbstractServiceProvider Resource\PostResource::class, Resource\DiscussionResource::class, Resource\NotificationResource::class, + Resource\AccessTokenResource::class, ]; }); diff --git a/framework/core/src/Api/Controller/CreateAccessTokenController.php b/framework/core/src/Api/Controller/CreateAccessTokenController.php deleted file mode 100644 index 4917d6bff..000000000 --- a/framework/core/src/Api/Controller/CreateAccessTokenController.php +++ /dev/null @@ -1,60 +0,0 @@ -assertRegistered(); - $actor->assertCan('createAccessToken'); - - $title = Arr::get($request->getParsedBody(), 'data.attributes.title'); - - $this->validation->make(compact('title'), [ - 'title' => 'required|string|max:255', - ])->validate(); - - $token = DeveloperAccessToken::generate($actor->id); - - $token->title = $title; - $token->last_activity_at = null; - - $token->save(); - - $this->events->dispatch(new DeveloperTokenCreated($token)); - - return $token; - } -} diff --git a/framework/core/src/Api/Controller/DeleteAccessTokenController.php b/framework/core/src/Api/Controller/DeleteAccessTokenController.php deleted file mode 100644 index af2b224ec..000000000 --- a/framework/core/src/Api/Controller/DeleteAccessTokenController.php +++ /dev/null @@ -1,46 +0,0 @@ -getQueryParams(), 'id'); - - $actor->assertRegistered(); - - $token = AccessToken::query()->findOrFail($id); - - /** @var Session|null $session */ - $session = $request->getAttribute('session'); - - // Current session should only be terminated through logout. - if ($session && $token->token === $session->get('access_token')) { - throw new PermissionDeniedException(); - } - - // Don't give away the existence of the token. - if ($actor->cannot('revoke', $token)) { - throw new ModelNotFoundException(); - } - - $token->delete(); - } -} diff --git a/framework/core/src/Api/Controller/ListAccessTokensController.php b/framework/core/src/Api/Controller/ListAccessTokensController.php deleted file mode 100644 index 98a3eeb14..000000000 --- a/framework/core/src/Api/Controller/ListAccessTokensController.php +++ /dev/null @@ -1,53 +0,0 @@ -assertRegistered(); - - $offset = $this->extractOffset($request); - $limit = $this->extractLimit($request); - $filter = $this->extractFilter($request); - - $tokens = $this->search->query(AccessToken::class, new SearchCriteria($actor, $filter, $limit, $offset)); - - $document->addPaginationLinks( - $this->url->to('api')->route('access-tokens.index'), - $request->getQueryParams(), - $offset, - $limit, - $tokens->areMoreResults() ? null : 0 - ); - - return $tokens->getResults(); - } -} diff --git a/framework/core/src/Api/Endpoint/Delete.php b/framework/core/src/Api/Endpoint/Delete.php index 075d5e010..65c5bcd2c 100644 --- a/framework/core/src/Api/Endpoint/Delete.php +++ b/framework/core/src/Api/Endpoint/Delete.php @@ -4,13 +4,13 @@ namespace Flarum\Api\Endpoint; use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasCustomRoute; +use Flarum\Api\Resource\Contracts\Deletable; use Nyholm\Psr7\Response; use Psr\Http\Message\ResponseInterface; use RuntimeException; use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Endpoint\Delete as BaseDelete; use Tobyz\JsonApiServer\Exception\ForbiddenException; -use Tobyz\JsonApiServer\Resource\Deletable; use function Tobyz\JsonApiServer\json_api_response; class Delete extends BaseDelete implements Endpoint @@ -56,7 +56,7 @@ class Delete extends BaseDelete implements Endpoint throw new ForbiddenException(); } - $resource->delete($model, $context); + $resource->deleteAction($model, $context); return true; } diff --git a/framework/core/src/Api/Resource/AbstractDatabaseResource.php b/framework/core/src/Api/Resource/AbstractDatabaseResource.php index 6b707e6d7..27c117839 100644 --- a/framework/core/src/Api/Resource/AbstractDatabaseResource.php +++ b/framework/core/src/Api/Resource/AbstractDatabaseResource.php @@ -14,6 +14,7 @@ use Flarum\Api\Resource\Contracts\{ use Flarum\Api\Resource\Concerns\Bootable; use Flarum\Api\Resource\Concerns\ResolvesValidationFactory; use Flarum\Foundation\DispatchEventsTrait; +use Flarum\User\User; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Arr; use RuntimeException; @@ -30,7 +31,9 @@ abstract class AbstractDatabaseResource extends BaseResource implements Deletable { use Bootable; - use DispatchEventsTrait; + use DispatchEventsTrait { + dispatchEventsFor as traitDispatchEventsFor; + } use ResolvesValidationFactory; abstract public function model(): string; @@ -74,11 +77,11 @@ abstract class AbstractDatabaseResource extends BaseResource implements return $model; } - public function delete(object $model, Context $context): void + public function deleteAction(object $model, Context $context): void { $this->deleting($model, $context); - parent::delete($model, $context); + $this->delete($model, $context); $this->deleted($model, $context); @@ -130,6 +133,13 @@ abstract class AbstractDatabaseResource extends BaseResource implements return null; } + public function dispatchEventsFor(mixed $entity, User $actor = null): void + { + if (method_exists($entity, 'releaseEvents')) { + $this->traitDispatchEventsFor($entity, $actor); + } + } + public function mutateDataBeforeValidation(Context $context, array $data): array { $dirty = $context->model->getDirty(); diff --git a/framework/core/src/Api/Resource/AccessTokenResource.php b/framework/core/src/Api/Resource/AccessTokenResource.php new file mode 100644 index 000000000..d8bbe7b99 --- /dev/null +++ b/framework/core/src/Api/Resource/AccessTokenResource.php @@ -0,0 +1,129 @@ +whereVisibleTo($context->getActor()); + } + + public function newModel(\Tobyz\JsonApiServer\Context $context): object + { + if ($context->endpoint instanceof Endpoint\Create && $context->collection instanceof self) { + $token = DeveloperAccessToken::make($context->getActor()->id); + $token->last_activity_at = null; + return $token; + } + + return parent::newModel($context); + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->can('createAccessToken'), + Endpoint\Delete::make() + ->authenticated(), + Endpoint\Index::make() + ->authenticated() + ->paginate(), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('token') + ->visible(function (AccessToken $token, Context $context) { + return $context->getActor()->id === $token->user_id && ! in_array('token', $token->getHidden(), true); + }), + Schema\Integer::make('userId'), + Schema\DateTime::make('createdAt'), + Schema\DateTime::make('lastActivityAt'), + Schema\Boolean::make('isCurrent') + ->get(function (AccessToken $token, Context $context) { + return $token->token === $context->request->getAttribute('session')->get('access_token'); + }), + Schema\Boolean::make('isSessionToken') + ->get(function (AccessToken $token) { + return in_array($token->type, [SessionAccessToken::$type, RememberAccessToken::$type], true); + }), + Schema\Str::make('title') + ->writableOnCreate() + ->requiredOnCreate() + ->maxLength(255), + Schema\Str::make('lastIpAddress'), + Schema\Str::make('device') + ->get(function (AccessToken $token) { + $translator = resolve(TranslatorInterface::class); + + $agent = new Agent(); + $agent->setUserAgent($token->last_user_agent); + + return $translator->trans('core.forum.security.browser_on_operating_system', [ + 'browser' => $agent->browser(), + 'os' => $agent->platform(), + ]); + }), + ]; + } + + public function created(object $model, \Tobyz\JsonApiServer\Context $context): ?object + { + $this->events->dispatch(new DeveloperTokenCreated($model)); + + return parent::created($model, $context); + } + + /** + * @param AccessToken $model + * @param \Flarum\Api\Context $context + * @throws PermissionDeniedException + */ + public function delete(object $model, \Tobyz\JsonApiServer\Context $context): void + { + /** @var Session|null $session */ + $session = $context->request->getAttribute('session'); + + // Current session should only be terminated through logout. + if ($session && $model->token === $session->get('access_token')) { + throw new PermissionDeniedException(); + } + + // Don't give away the existence of the token. + if ($context->getActor()->cannot('revoke', $model)) { + throw new ModelNotFoundException(); + } + + $model->delete(); + } +} diff --git a/framework/core/src/Api/Resource/Contracts/Deletable.php b/framework/core/src/Api/Resource/Contracts/Deletable.php index fa9e04bd9..3e177ba5c 100644 --- a/framework/core/src/Api/Resource/Contracts/Deletable.php +++ b/framework/core/src/Api/Resource/Contracts/Deletable.php @@ -2,9 +2,10 @@ namespace Flarum\Api\Resource\Contracts; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Resource\Deletable as BaseDeletable; interface Deletable extends BaseDeletable { - // + public function deleteAction(object $model, Context $context): void; } diff --git a/framework/core/src/Api/routes.php b/framework/core/src/Api/routes.php index bea87af6d..0957f9963 100644 --- a/framework/core/src/Api/routes.php +++ b/framework/core/src/Api/routes.php @@ -19,27 +19,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { $route->toController(Controller\ShowForumController::class) ); - // List access tokens - $map->get( - '/access-tokens', - 'access-tokens.index', - $route->toController(Controller\ListAccessTokensController::class) - ); - - // Create access token - $map->post( - '/access-tokens', - 'access-tokens.create', - $route->toController(Controller\CreateAccessTokenController::class) - ); - - // Delete access token - $map->delete( - '/access-tokens/{id}', - 'access-tokens.delete', - $route->toController(Controller\DeleteAccessTokenController::class) - ); - // Create authentication token $map->post( '/token', diff --git a/framework/core/src/Http/AccessToken.php b/framework/core/src/Http/AccessToken.php index 41f57c97d..62642750f 100644 --- a/framework/core/src/Http/AccessToken.php +++ b/framework/core/src/Http/AccessToken.php @@ -75,6 +75,14 @@ class AccessToken extends AbstractModel * Generate an access token for the specified user. */ public static function generate(int $userId): static + { + $token = static::make($userId); + $token->save(); + + return $token; + } + + public static function make(int $userId): static { if (static::class === self::class) { throw new \Exception('Use of AccessToken::generate() is not allowed: use the `generate` method on one of the subclasses.'); @@ -87,7 +95,6 @@ class AccessToken extends AbstractModel $token->user_id = $userId; $token->created_at = Carbon::now(); $token->last_activity_at = Carbon::now(); - $token->save(); return $token; } diff --git a/framework/core/tests/integration/api/access_tokens/CreateTest.php b/framework/core/tests/integration/api/access_tokens/CreateTest.php index c7aac9cb8..095506537 100644 --- a/framework/core/tests/integration/api/access_tokens/CreateTest.php +++ b/framework/core/tests/integration/api/access_tokens/CreateTest.php @@ -61,7 +61,7 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(201, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -84,7 +84,7 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(403, $response->getStatusCode()); + $this->assertEquals(403, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -95,10 +95,16 @@ class CreateTest extends TestCase $response = $this->send( $this->request('POST', '/api/access-tokens', [ 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'type' => 'access-tokens', + 'attributes' => [] + ] + ] ]) ); - $this->assertEquals(422, $response->getStatusCode()); + $this->assertEquals(422, $response->getStatusCode(), (string) $response->getBody()); } public function canCreateTokens(): array