1
0
mirror of https://github.com/flarum/core.git synced 2025-08-05 07:57:46 +02:00

feat: AccessTokenResource

This commit is contained in:
Sami Mazouz
2024-02-17 13:24:30 +01:00
parent dc71b82e3e
commit 0619662c48
11 changed files with 164 additions and 190 deletions

View File

@@ -40,6 +40,7 @@ class ApiServiceProvider extends AbstractServiceProvider
Resource\PostResource::class,
Resource\DiscussionResource::class,
Resource\NotificationResource::class,
Resource\AccessTokenResource::class,
];
});

View File

@@ -1,60 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\AccessTokenSerializer;
use Flarum\Http\DeveloperAccessToken;
use Flarum\Http\Event\DeveloperTokenCreated;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Validation\Factory;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
/**
* Not to be confused with the CreateTokenController,
* this controller is used by the actor to manually create a developer type access token.
*/
class CreateAccessTokenController extends AbstractCreateController
{
public ?string $serializer = AccessTokenSerializer::class;
public function __construct(
protected Dispatcher $events,
protected Factory $validation
) {
}
public function data(ServerRequestInterface $request, Document $document): DeveloperAccessToken
{
$actor = RequestUtil::getActor($request);
$actor->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;
}
}

View File

@@ -1,46 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Http\AccessToken;
use Flarum\Http\RequestUtil;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class DeleteAccessTokenController extends AbstractDeleteController
{
protected function delete(ServerRequestInterface $request): void
{
$actor = RequestUtil::getActor($request);
$id = Arr::get($request->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();
}
}

View File

@@ -1,53 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\AccessTokenSerializer;
use Flarum\Http\AccessToken;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListAccessTokensController extends AbstractListController
{
public ?string $serializer = AccessTokenSerializer::class;
public function __construct(
protected UrlGenerator $url,
protected SearchManager $search
) {
}
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$actor->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();
}
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -0,0 +1,129 @@
<?php
namespace Flarum\Api\Resource;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Schema;
use Flarum\Http\AccessToken;
use Flarum\Http\DeveloperAccessToken;
use Flarum\Http\Event\DeveloperTokenCreated;
use Flarum\Http\RememberAccessToken;
use Flarum\Http\SessionAccessToken;
use Flarum\Locale\TranslatorInterface;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Jenssegers\Agent\Agent;
class AccessTokenResource extends AbstractDatabaseResource
{
public function type(): string
{
return 'access-tokens';
}
public function model(): string
{
return AccessToken::class;
}
public function scope(Builder $query, \Tobyz\JsonApiServer\Context $context): void
{
$query->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();
}
}

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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