mirror of
https://github.com/flarum/core.git
synced 2025-08-09 01:46:35 +02:00
feat: access tokens user management UI (#3587)
Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com> Co-authored-by: David <hi@davwheat.dev>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
<?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\Tests\integration\api\access_tokens;
|
||||
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
|
||||
class CreateTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->prepareDatabase([
|
||||
'users' => [
|
||||
$this->normalUser(),
|
||||
['id' => 3, 'username' => 'normal3', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'normal3@machine.local', 'is_email_confirmed' => 1]
|
||||
],
|
||||
'access_tokens' => [],
|
||||
'groups' => [
|
||||
['id' => 10, 'name_plural' => 'Acme', 'name_singular' => 'Acme']
|
||||
],
|
||||
'group_user' => [
|
||||
['user_id' => 3, 'group_id' => 10]
|
||||
],
|
||||
'group_permission' => [
|
||||
['permission' => 'createAccessToken', 'group_id' => 10]
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider canCreateTokens
|
||||
* @test
|
||||
*/
|
||||
public function user_can_create_developer_tokens(int $authenticatedAs)
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/access-tokens', [
|
||||
'authenticatedAs' => $authenticatedAs,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'title' => 'Dev'
|
||||
]
|
||||
]
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider cannotCreateTokens
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_delete_other_users_tokens(int $authenticatedAs)
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/access-tokens', [
|
||||
'authenticatedAs' => $authenticatedAs,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'title' => 'Dev'
|
||||
]
|
||||
]
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(403, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_create_token_without_title()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/access-tokens', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(422, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function canCreateTokens(): array
|
||||
{
|
||||
return [
|
||||
[1], // Admin
|
||||
[3], // User with permission
|
||||
];
|
||||
}
|
||||
|
||||
public function cannotCreateTokens(): array
|
||||
{
|
||||
return [
|
||||
[2]
|
||||
];
|
||||
}
|
||||
}
|
@@ -0,0 +1,210 @@
|
||||
<?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\Tests\integration\api\access_tokens;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Http\AccessToken;
|
||||
use Flarum\Http\DeveloperAccessToken;
|
||||
use Flarum\Http\RememberAccessToken;
|
||||
use Flarum\Http\SessionAccessToken;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
|
||||
class DeleteTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->prepareDatabase([
|
||||
'users' => [
|
||||
$this->normalUser(),
|
||||
['id' => 3, 'username' => 'normal3', 'email' => 'normal3@machine.local', 'is_email_confirmed' => 1],
|
||||
['id' => 4, 'username' => 'normal4', 'email' => 'normal4@machine.local', 'is_email_confirmed' => 1],
|
||||
],
|
||||
'access_tokens' => [
|
||||
['id' => 1, 'token' => 'a', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'],
|
||||
['id' => 2, 'token' => 'b', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session_remember'],
|
||||
['id' => 3, 'token' => 'c', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'],
|
||||
['id' => 4, 'token' => 'd', 'user_id' => 2, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'],
|
||||
['id' => 5, 'token' => 'e', 'user_id' => 2, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'],
|
||||
['id' => 6, 'token' => 'f', 'user_id' => 3, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'],
|
||||
],
|
||||
'groups' => [
|
||||
['id' => 100, 'name_singular' => 'test', 'name_plural' => 'test']
|
||||
],
|
||||
'group_user' => [
|
||||
['user_id' => 4, 'group_id' => 100]
|
||||
],
|
||||
'group_permission' => [
|
||||
['group_id' => 100, 'permission' => 'moderateAccessTokens']
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider canDeleteTokensDataProvider
|
||||
* @test
|
||||
*/
|
||||
public function user_can_delete_tokens(int $authenticatedAs, array $canDeleteIds)
|
||||
{
|
||||
foreach ($canDeleteIds as $id) {
|
||||
$response = $this->send(
|
||||
$this->request('DELETE', "/api/access-tokens/$id", compact('authenticatedAs'))
|
||||
);
|
||||
|
||||
$this->assertEquals(204, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider cannotDeleteTokensDataProvider
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_delete_tokens(int $authenticatedAs, array $canDeleteIds)
|
||||
{
|
||||
foreach ($canDeleteIds as $id) {
|
||||
$response = $this->send(
|
||||
$this->request('DELETE', "/api/access-tokens/$id", compact('authenticatedAs'))
|
||||
);
|
||||
|
||||
$this->assertEquals(404, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_delete_current_session_token()
|
||||
{
|
||||
$responseWithSession = $this->send(
|
||||
$this->requestWithCsrfToken(
|
||||
$this->request('POST', '/login', [
|
||||
'json' => [
|
||||
'identification' => 'admin',
|
||||
'password' => 'password',
|
||||
]
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
$sessionToken = AccessToken::query()
|
||||
->where('user_id', 1)
|
||||
->where('type', SessionAccessToken::$type)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$csrfToken = $responseWithSession->getHeaderLine('X-CSRF-Token');
|
||||
|
||||
$request = $this->requestWithCookiesFrom(
|
||||
$this->request('DELETE', "/api/access-tokens/$sessionToken->id")->withHeader('X-CSRF-Token', $csrfToken),
|
||||
$responseWithSession
|
||||
);
|
||||
|
||||
$response = $this->send($request);
|
||||
|
||||
$this->assertEquals(403, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function user_can_terminate_all_other_sessions()
|
||||
{
|
||||
$responseWithSession = $this->send(
|
||||
$this->requestWithCsrfToken(
|
||||
$this->request('POST', '/login', [
|
||||
'json' => [
|
||||
'identification' => 'admin',
|
||||
'password' => 'password',
|
||||
]
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
$sessionToken = AccessToken::query()
|
||||
->where('user_id', 1)
|
||||
->where('type', SessionAccessToken::$type)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$csrfToken = $responseWithSession->getHeaderLine('X-CSRF-Token');
|
||||
|
||||
$request = $this->requestWithCookiesFrom(
|
||||
$this->request('DELETE', '/api/sessions')->withHeader('X-CSRF-Token', $csrfToken),
|
||||
$responseWithSession
|
||||
);
|
||||
|
||||
$response = $this->send($request);
|
||||
|
||||
$this->assertEquals(204, $response->getStatusCode());
|
||||
$this->assertEquals(
|
||||
1, // It doesn't delete current session
|
||||
AccessToken::query()
|
||||
->where('user_id', 1)
|
||||
->where(function ($query) {
|
||||
$query
|
||||
->where('type', SessionAccessToken::$type)
|
||||
->orWhere('type', RememberAccessToken::$type);
|
||||
})
|
||||
->count()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function terminting_all_other_sessions_does_not_delete_dev_tokens()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('DELETE', '/api/sessions', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(204, $response->getStatusCode());
|
||||
$this->assertEquals(
|
||||
1,
|
||||
AccessToken::query()
|
||||
->where('user_id', 1)
|
||||
->where('type', DeveloperAccessToken::$type)
|
||||
->count()
|
||||
);
|
||||
}
|
||||
|
||||
public function canDeleteTokensDataProvider(): array
|
||||
{
|
||||
return [
|
||||
// Admin can delete any user tokens.
|
||||
[1, [1, 2, 3, 4, 5, 6]],
|
||||
|
||||
// User with moderateAccessTokens permission can delete any tokens.
|
||||
[4, [1, 2, 3, 4, 5, 6]],
|
||||
|
||||
// Normal users can only delete their own.
|
||||
[2, [4, 5]],
|
||||
[3, [6]],
|
||||
];
|
||||
}
|
||||
|
||||
public function cannotDeleteTokensDataProvider(): array
|
||||
{
|
||||
return [
|
||||
// Normal users cannot delete other users' tokens.
|
||||
[2, [1, 2]],
|
||||
[3, [1, 4]],
|
||||
];
|
||||
}
|
||||
}
|
184
framework/core/tests/integration/api/access_tokens/ListTest.php
Normal file
184
framework/core/tests/integration/api/access_tokens/ListTest.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?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\Tests\integration\api\access_tokens;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Http\AccessToken;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ListTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->prepareDatabase([
|
||||
'users' => [
|
||||
$this->normalUser(),
|
||||
['id' => 3, 'username' => 'normal3', 'email' => 'normal3@machine.local', 'is_email_confirmed' => 1],
|
||||
['id' => 4, 'username' => 'normal4', 'email' => 'normal4@machine.local', 'is_email_confirmed' => 1],
|
||||
],
|
||||
'access_tokens' => [
|
||||
['id' => 1, 'token' => 'a', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'],
|
||||
['id' => 2, 'token' => 'b', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session_remember'],
|
||||
['id' => 3, 'token' => 'c', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'],
|
||||
['id' => 4, 'token' => 'd', 'user_id' => 2, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'],
|
||||
['id' => 5, 'token' => 'e', 'user_id' => 2, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'],
|
||||
['id' => 6, 'token' => 'f', 'user_id' => 3, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'],
|
||||
],
|
||||
'groups' => [
|
||||
['id' => 100, 'name_singular' => 'test', 'name_plural' => 'test']
|
||||
],
|
||||
'group_user' => [
|
||||
['user_id' => 4, 'group_id' => 100]
|
||||
],
|
||||
'group_permission' => [
|
||||
['group_id' => 100, 'permission' => 'moderateAccessTokens']
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider canViewTokensDataProvider
|
||||
* @test
|
||||
*/
|
||||
public function user_can_view_access_tokens(int $authenticatedAs, array $canViewIds)
|
||||
{
|
||||
$response = $this->send(
|
||||
$request = $this->request('GET', '/api/access-tokens', compact('authenticatedAs'))
|
||||
);
|
||||
|
||||
$data = Arr::get(json_decode($response->getBody()->getContents(), true), 'data');
|
||||
|
||||
$testsTokenId = AccessToken::findValid($request->getAttribute('tests_token'))->id;
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEqualsCanonicalizing(array_merge($canViewIds, [$testsTokenId]), Arr::pluck($data, 'id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider cannotSeeTokenValuesDataProvider
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_see_token_values(int $authenticatedAs, ?int $userId, array $tokenValues)
|
||||
{
|
||||
if ($userId) {
|
||||
$filters = [
|
||||
'filter' => ['user' => $userId]
|
||||
];
|
||||
}
|
||||
|
||||
$response = $this->send(
|
||||
$this
|
||||
->request('GET', '/api/access-tokens', compact('authenticatedAs'))
|
||||
->withQueryParams($filters ?? [])
|
||||
);
|
||||
|
||||
$data = Arr::get(json_decode($response->getBody()->getContents(), true), 'data');
|
||||
|
||||
// There is always an additional null value to refer to the current session.
|
||||
if (! $userId || $authenticatedAs === $userId) {
|
||||
$tokenValues[] = null;
|
||||
}
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEqualsCanonicalizing($tokenValues, Arr::pluck($data, 'attributes.token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider needsPermissionToUseUserfilterDataProvider
|
||||
* @test
|
||||
*/
|
||||
public function user_needs_permissions_to_use_user_filter(int $authenticatedAs, int $userId, array $canViewIds)
|
||||
{
|
||||
$response = $this->send(
|
||||
$request = $this->request('GET', '/api/access-tokens', compact('authenticatedAs'))
|
||||
->withQueryParams([
|
||||
'filter' => ['user' => $userId]
|
||||
])
|
||||
);
|
||||
|
||||
$data = Arr::get(json_decode($response->getBody()->getContents(), true), 'data');
|
||||
$testsTokenId = AccessToken::findValid($request->getAttribute('tests_token'))->id;
|
||||
|
||||
if ($authenticatedAs === $userId) {
|
||||
$canViewIds[] = $testsTokenId;
|
||||
}
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEqualsCanonicalizing($canViewIds, Arr::pluck($data, 'id'));
|
||||
}
|
||||
|
||||
public function canViewTokensDataProvider(): array
|
||||
{
|
||||
return [
|
||||
// Admin can view his and others access tokens.
|
||||
[1, [1, 2, 3, 4, 5, 6]],
|
||||
|
||||
// User with moderateAccessTokens permission can view other users access tokens.
|
||||
[4, [1, 2, 3, 4, 5, 6]],
|
||||
|
||||
// Normal users can only view their own.
|
||||
[2, [4, 5]],
|
||||
[3, [6]],
|
||||
];
|
||||
}
|
||||
|
||||
public function cannotSeeTokenValuesDataProvider(): array
|
||||
{
|
||||
return [
|
||||
// Admin can only see his own developer token value.
|
||||
[1, null, [null, null, null, null, null, 'c']],
|
||||
[1, 1, [null, null, 'c']],
|
||||
[1, 2, [null, null]],
|
||||
[1, 3, [null]],
|
||||
|
||||
// User with moderateAccessTokens permission can only see his own developer token value.
|
||||
[4, null, [null, null, null, null, null, null]],
|
||||
[4, 1, [null, null, null]],
|
||||
[4, 2, [null, null]],
|
||||
[4, 3, [null]],
|
||||
|
||||
// Normal users can only see their own developer token.
|
||||
[2, null, ['d', 'e']],
|
||||
[3, null, ['f']],
|
||||
];
|
||||
}
|
||||
|
||||
public function needsPermissionToUseUserfilterDataProvider(): array
|
||||
{
|
||||
return [
|
||||
// Admin can use user filter.
|
||||
[1, 1, [1, 2, 3]],
|
||||
[1, 2, [4, 5]],
|
||||
[1, 3, [6]],
|
||||
[1, 4, []],
|
||||
|
||||
// User with moderateAccessTokens permission can use user filter.
|
||||
[4, 1, [1, 2, 3]],
|
||||
[4, 2, [4, 5]],
|
||||
[4, 3, [6]],
|
||||
[4, 4, []],
|
||||
|
||||
// Normal users cannot use the user filter
|
||||
[2, 1, []],
|
||||
[2, 2, [5, 4]],
|
||||
[3, 2, []],
|
||||
[3, 3, [6]],
|
||||
];
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user