diff --git a/api/config/routes.php b/api/config/routes.php index 34f5653..be93a8c 100644 --- a/api/config/routes.php +++ b/api/config/routes.php @@ -8,6 +8,8 @@ return [ 'DELETE /v1/oauth2/' => 'oauth/clients/delete', 'POST /v1/oauth2//reset' => 'oauth/clients/reset', 'GET /v1/accounts//oauth2/clients' => 'oauth/clients/get-per-account', + 'GET /v1/accounts//oauth2/authorized' => 'oauth/clients/get-authorized-clients', + 'DELETE /v1/accounts//oauth2/authorized/' => 'oauth/clients/revoke-client', '/account/v1/info' => 'oauth/identity/index', // Accounts module routes diff --git a/api/modules/authserver/models/AuthenticationForm.php b/api/modules/authserver/models/AuthenticationForm.php index 92dfdb9..7257ecd 100644 --- a/api/modules/authserver/models/AuthenticationForm.php +++ b/api/modules/authserver/models/AuthenticationForm.php @@ -87,17 +87,19 @@ class AuthenticationForm extends ApiForm { $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); $dataModel = new AuthenticateData($account, (string)$token, $this->clientToken); /** @var OauthSession|null $minecraftOauthSession */ - $hasMinecraftOauthSession = $account->getOauthSessions() + $minecraftOauthSession = $account->getOauthSessions() ->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER]) - ->exists(); - if ($hasMinecraftOauthSession === false) { + ->one(); + if ($minecraftOauthSession === null) { $minecraftOauthSession = new OauthSession(); $minecraftOauthSession->account_id = $account->id; $minecraftOauthSession->client_id = OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER; $minecraftOauthSession->scopes = [P::MINECRAFT_SERVER_SESSION]; - Assert::true($minecraftOauthSession->save()); } + $minecraftOauthSession->last_used_at = time(); + Assert::true($minecraftOauthSession->save()); + Authserver::info("User with id = {$account->id}, username = '{$account->username}' and email = '{$account->email}' successfully logged in."); return $dataModel; diff --git a/api/modules/authserver/models/RefreshTokenForm.php b/api/modules/authserver/models/RefreshTokenForm.php index cf21657..cf632fa 100644 --- a/api/modules/authserver/models/RefreshTokenForm.php +++ b/api/modules/authserver/models/RefreshTokenForm.php @@ -70,17 +70,19 @@ class RefreshTokenForm extends ApiForm { // TODO: This behavior duplicates with the AuthenticationForm. Need to find a way to avoid duplication. /** @var OauthSession|null $minecraftOauthSession */ - $hasMinecraftOauthSession = $account->getOauthSessions() + $minecraftOauthSession = $account->getOauthSessions() ->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER]) - ->exists(); - if ($hasMinecraftOauthSession === false) { + ->one(); + if ($minecraftOauthSession === null) { $minecraftOauthSession = new OauthSession(); $minecraftOauthSession->account_id = $account->id; $minecraftOauthSession->client_id = OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER; $minecraftOauthSession->scopes = [P::MINECRAFT_SERVER_SESSION]; - Assert::true($minecraftOauthSession->save()); } + $minecraftOauthSession->last_used_at = time(); + Assert::true($minecraftOauthSession->save()); + return new AuthenticateData($account, (string)$token, $this->clientToken); } diff --git a/api/modules/oauth/controllers/ClientsController.php b/api/modules/oauth/controllers/ClientsController.php index 3b42ffe..1888fae 100644 --- a/api/modules/oauth/controllers/ClientsController.php +++ b/api/modules/oauth/controllers/ClientsController.php @@ -1,4 +1,6 @@ ['update', 'delete', 'reset'], 'allow' => true, 'permissions' => [P::MANAGE_OAUTH_CLIENTS], - 'roleParams' => function() { - return [ - 'clientId' => Yii::$app->request->get('clientId'), - ]; - }, + 'roleParams' => fn() => [ + 'clientId' => Yii::$app->request->get('clientId'), + ], ], [ 'actions' => ['get'], 'allow' => true, 'permissions' => [P::VIEW_OAUTH_CLIENTS], - 'roleParams' => function() { - return [ - 'clientId' => Yii::$app->request->get('clientId'), - ]; - }, + 'roleParams' => fn() => [ + 'clientId' => Yii::$app->request->get('clientId'), + ], ], [ 'actions' => ['get-per-account'], 'allow' => true, 'permissions' => [P::VIEW_OAUTH_CLIENTS], - 'roleParams' => function() { - return [ - 'accountId' => Yii::$app->request->get('accountId'), - ]; - }, + 'roleParams' => fn() => [ + 'accountId' => Yii::$app->request->get('accountId'), + ], ], + [ + 'actions' => ['get-authorized-clients', 'revoke-client'], + 'allow' => true, + 'permissions' => [P::MANAGE_OAUTH_SESSIONS], + 'roleParams' => fn() => [ + 'accountId' => Yii::$app->request->get('accountId'), + ], + ], + ], + ], + 'verb' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'get' => ['GET'], + 'create' => ['POST'], + 'update' => ['PUT'], + 'delete' => ['DELETE'], + 'reset' => ['POST'], + 'get-per-account' => ['GET'], + 'get-authorized-clients' => ['GET'], + 'revoke-client' => ['DELETE'], ], ], ]); @@ -128,18 +148,60 @@ class ClientsController extends Controller { } public function actionGetPerAccount(int $accountId): array { - /** @var Account|null $account */ - $account = Account::findOne(['id' => $accountId]); - if ($account === null) { - throw new NotFoundHttpException(); - } - /** @var OauthClient[] $clients */ - $clients = $account->getOauthClients()->orderBy(['created_at' => SORT_ASC])->all(); + $clients = $this->findAccount($accountId)->getOauthClients()->orderBy(['created_at' => SORT_ASC])->all(); return array_map(fn(OauthClient $client): array => $this->formatClient($client), $clients); } + public function actionGetAuthorizedClients(int $accountId): array { + $account = $this->findAccount($accountId); + + $result = []; + /** @var \common\models\OauthSession[] $oauthSessions */ + $oauthSessions = $account->getOauthSessions() + ->innerJoinWith(['client c' => function(ActiveQuery $query): void { + $query->andOnCondition(['c.type' => OauthClient::TYPE_APPLICATION]); + }]) + ->andWhere([ + 'OR', + ['revoked_at' => null], + ['>', 'last_used_at', new Expression('`revoked_at`')], + ]) + ->all(); + foreach ($oauthSessions as $oauthSession) { + $client = $oauthSession->client; + if ($client === null) { + continue; + } + + $result[] = [ + 'id' => $client->id, + 'name' => $client->name, + 'description' => $client->description, + 'scopes' => $oauthSession->getScopes(), + 'authorizedAt' => $oauthSession->created_at, + 'lastUsedAt' => $oauthSession->last_used_at, + ]; + } + + return $result; + } + + public function actionRevokeClient(int $accountId, string $clientId): ?array { + $account = $this->findAccount($accountId); + $client = $this->findOauthClient($clientId); + + /** @var \common\models\OauthSession|null $session */ + $session = $account->getOauthSessions()->andWhere(['client_id' => $client->id])->one(); + if ($session !== null && !$session->isRevoked()) { + $session->revoked_at = time(); + Assert::true($session->save()); + } + + return ['success' => true]; + } + private function formatClient(OauthClient $client): array { $result = [ 'clientId' => $client->id, @@ -168,16 +230,26 @@ class ClientsController extends Controller { try { $model = OauthClientFormFactory::create($client); } catch (UnsupportedOauthClientType $e) { - Yii::warning('Someone tried use ' . $client->type . ' type of oauth form.'); + Yii::warning('Someone tried to use ' . $client->type . ' type of oauth form.'); throw new NotFoundHttpException(null, 0, $e); } return $model; } + private function findAccount(int $id): Account { + /** @var Account|null $account */ + $account = Account::findOne(['id' => $id]); + if ($account === null) { + throw new NotFoundHttpException(); + } + + return $account; + } + private function findOauthClient(string $clientId): OauthClient { /** @var OauthClient|null $client */ - $client = OauthClient::findOne($clientId); + $client = OauthClient::findOne(['id' => $clientId]); if ($client === null) { throw new NotFoundHttpException(); } diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index 1a3b674..16cac16 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -223,6 +223,10 @@ class OauthProcess { return false; } + if ($session->isRevoked()) { + return false; + } + return empty(array_diff($this->getScopesList($request), $session->getScopes())); } @@ -235,6 +239,7 @@ class OauthProcess { } $session->scopes = array_unique(array_merge($session->getScopes(), $this->getScopesList($request))); + $session->last_used_at = time(); Assert::true($session->save()); } @@ -346,7 +351,6 @@ class OauthProcess { } private function findOauthSession(Account $account, OauthClient $client): ?OauthSession { - /** @noinspection PhpIncompatibleReturnTypeInspection */ return $account->getOauthSessions()->andWhere(['client_id' => $client->id])->one(); } diff --git a/api/rbac/Permissions.php b/api/rbac/Permissions.php index f8d0c8d..9edaccc 100644 --- a/api/rbac/Permissions.php +++ b/api/rbac/Permissions.php @@ -16,6 +16,7 @@ final class Permissions { public const RESTORE_ACCOUNT = 'restore_account'; public const BLOCK_ACCOUNT = 'block_account'; public const COMPLETE_OAUTH_FLOW = 'complete_oauth_flow'; + public const MANAGE_OAUTH_SESSIONS = 'manage_oauth_sessions'; public const CREATE_OAUTH_CLIENTS = 'create_oauth_clients'; public const VIEW_OAUTH_CLIENTS = 'view_oauth_clients'; public const MANAGE_OAUTH_CLIENTS = 'manage_oauth_clients'; @@ -32,6 +33,7 @@ final class Permissions { public const DELETE_OWN_ACCOUNT = 'delete_own_account'; public const RESTORE_OWN_ACCOUNT = 'restore_own_account'; public const MINECRAFT_SERVER_SESSION = 'minecraft_server_session'; + public const MANAGE_OWN_OAUTH_SESSIONS = 'manage_own_oauth_sessions'; public const VIEW_OWN_OAUTH_CLIENTS = 'view_own_oauth_clients'; public const MANAGE_OWN_OAUTH_CLIENTS = 'manage_own_oauth_clients'; diff --git a/api/tests/functional/accounts/GetAuthorizedClientsCest.php b/api/tests/functional/accounts/GetAuthorizedClientsCest.php new file mode 100644 index 0000000..a0c88a2 --- /dev/null +++ b/api/tests/functional/accounts/GetAuthorizedClientsCest.php @@ -0,0 +1,40 @@ +amAuthenticated('admin'); + $I->sendGET("/api/v1/accounts/{$id}/oauth2/authorized"); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + [ + 'id' => 'test1', + 'name' => 'Test1', + 'description' => 'Some description', + 'scopes' => ['minecraft_server_session', 'obtain_own_account_info'], + 'authorizedAt' => 1479944472, + 'lastUsedAt' => 1479944472, + ], + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.[?(@.id="tlauncher")]'); + } + + public function testGetForNotOwnIdentity(FunctionalTester $I) { + $I->amAuthenticated('admin'); + $I->sendGET('/api/v1/accounts/2/oauth2/authorized'); + $I->canSeeResponseCodeIs(403); + $I->canSeeResponseContainsJson([ + 'name' => 'Forbidden', + 'message' => 'You are not allowed to perform this action.', + 'code' => 0, + 'status' => 403, + ]); + } + +} diff --git a/api/tests/functional/accounts/RevokeAuthorizedClientCest.php b/api/tests/functional/accounts/RevokeAuthorizedClientCest.php new file mode 100644 index 0000000..d49f5cf --- /dev/null +++ b/api/tests/functional/accounts/RevokeAuthorizedClientCest.php @@ -0,0 +1,45 @@ +amAuthenticated('admin'); + $I->sendDELETE("/api/v1/accounts/{$id}/oauth2/authorized/test1"); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + + $I->sendGET("/api/v1/accounts/{$id}/oauth2/authorized"); + $I->cantSeeResponseJsonMatchesJsonPath('$.[?(@.id="test1")]'); + } + + public function testRevokeAlreadyRevokedClient(FunctionalTester $I) { + $id = $I->amAuthenticated('admin'); + $I->sendDELETE("/api/v1/accounts/{$id}/oauth2/authorized/tlauncher"); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + } + + public function testRevokeForNotOwnIdentity(FunctionalTester $I) { + $I->amAuthenticated('admin'); + $I->sendDELETE('/api/v1/accounts/2/oauth2/authorized/test1'); + $I->canSeeResponseCodeIs(403); + $I->canSeeResponseContainsJson([ + 'name' => 'Forbidden', + 'message' => 'You are not allowed to perform this action.', + 'code' => 0, + 'status' => 403, + ]); + } + +} diff --git a/common/models/OauthSession.php b/common/models/OauthSession.php index d3f37d2..c87b93b 100644 --- a/common/models/OauthSession.php +++ b/common/models/OauthSession.php @@ -16,10 +16,14 @@ use yii\db\ActiveRecord; * @property array $scopes * @property int $created_at * @property int|null $revoked_at + * @property int $last_used_at * * Relations: - * @property-read OauthClient $client + * @property-read OauthClient|null $client * @property-read Account $account + * + * Mixins: + * @mixin TimestampBehavior */ class OauthSession extends ActiveRecord { @@ -36,6 +40,10 @@ class OauthSession extends ActiveRecord { ]; } + public function isRevoked(): bool { + return $this->revoked_at > $this->last_used_at; + } + public function getClient(): ActiveQuery { return $this->hasOne(OauthClient::class, ['id' => 'client_id']); } diff --git a/common/tests/fixtures/data/oauth-sessions.php b/common/tests/fixtures/data/oauth-sessions.php index 70023bc..6cbbce0 100644 --- a/common/tests/fixtures/data/oauth-sessions.php +++ b/common/tests/fixtures/data/oauth-sessions.php @@ -7,6 +7,7 @@ return [ 'scopes' => null, 'created_at' => 1479944472, 'revoked_at' => null, + 'last_used_at' => 1479944472, ], 'revoked-tlauncher' => [ 'account_id' => 1, @@ -15,6 +16,7 @@ return [ 'scopes' => null, 'created_at' => Carbon\Carbon::create(2019, 8, 1, 0, 0, 0, 'Europe/Minsk')->unix(), 'revoked_at' => Carbon\Carbon::create(2019, 8, 1, 1, 2, 0, 'Europe/Minsk')->unix(), + 'last_used_at' => Carbon\Carbon::create(2019, 8, 1, 0, 0, 0, 'Europe/Minsk')->unix(), ], 'revoked-minecraft-game-launchers' => [ 'account_id' => 1, @@ -23,6 +25,7 @@ return [ 'scopes' => null, 'created_at' => Carbon\Carbon::create(2019, 8, 1, 0, 0, 0, 'Europe/Minsk')->unix(), 'revoked_at' => Carbon\Carbon::create(2019, 8, 1, 1, 2, 0, 'Europe/Minsk')->unix(), + 'last_used_at' => Carbon\Carbon::create(2019, 8, 1, 0, 0, 0, 'Europe/Minsk')->unix(), ], 'banned-account-session' => [ 'account_id' => 10, @@ -31,6 +34,7 @@ return [ 'scopes' => null, 'created_at' => 1481421663, 'revoked_at' => null, + 'last_used_at' => 1481421663, ], 'deleted-client-session' => [ 'account_id' => 1, @@ -39,6 +43,7 @@ return [ 'scopes' => null, 'created_at' => 1519510065, 'revoked_at' => null, + 'last_used_at' => 1519510065, ], 'actual-deleted-client-session' => [ 'account_id' => 2, @@ -47,5 +52,6 @@ return [ 'scopes' => null, 'created_at' => 1519511568, 'revoked_at' => null, + 'last_used_at' => 1519511568, ], ]; diff --git a/console/controllers/RbacController.php b/console/controllers/RbacController.php index 1b4846b..bff6673 100644 --- a/console/controllers/RbacController.php +++ b/console/controllers/RbacController.php @@ -38,6 +38,7 @@ class RbacController extends Controller { $permViewOauthClients = $this->createPermission(P::VIEW_OAUTH_CLIENTS); $permManageOauthClients = $this->createPermission(P::MANAGE_OAUTH_CLIENTS); $permCompleteOauthFlow = $this->createPermission(P::COMPLETE_OAUTH_FLOW, AccountOwner::class); + $permManageOauthSessions = $this->createPermission(P::MANAGE_OAUTH_SESSIONS); $permObtainAccountEmail = $this->createPermission(P::OBTAIN_ACCOUNT_EMAIL); $permObtainExtendedAccountInfo = $this->createPermission(P::OBTAIN_EXTENDED_ACCOUNT_INFO); @@ -53,6 +54,7 @@ class RbacController extends Controller { $permDeleteOwnAccount = $this->createPermission(P::DELETE_OWN_ACCOUNT, AccountOwner::class); $permRestoreOwnAccount = $this->createPermission(P::RESTORE_OWN_ACCOUNT, AccountOwner::class); $permMinecraftServerSession = $this->createPermission(P::MINECRAFT_SERVER_SESSION); + $permManageOwnOauthSessions = $this->createPermission(P::MANAGE_OWN_OAUTH_SESSIONS, AccountOwner::class); $permViewOwnOauthClients = $this->createPermission(P::VIEW_OWN_OAUTH_CLIENTS, OauthClientOwner::class); $permManageOwnOauthClients = $this->createPermission(P::MANAGE_OWN_OAUTH_CLIENTS, OauthClientOwner::class); @@ -69,6 +71,7 @@ class RbacController extends Controller { $authManager->addChild($permManageOwnTwoFactorAuth, $permManageTwoFactorAuth); $authManager->addChild($permDeleteOwnAccount, $permDeleteAccount); $authManager->addChild($permRestoreOwnAccount, $permRestoreAccount); + $authManager->addChild($permManageOwnOauthSessions, $permManageOauthSessions); $authManager->addChild($permViewOwnOauthClients, $permViewOauthClients); $authManager->addChild($permManageOwnOauthClients, $permManageOauthClients); @@ -86,6 +89,7 @@ class RbacController extends Controller { $authManager->addChild($roleAccountsWebUser, $permRestoreOwnAccount); $authManager->addChild($roleAccountsWebUser, $permCompleteOauthFlow); $authManager->addChild($roleAccountsWebUser, $permCreateOauthClients); + $authManager->addChild($roleAccountsWebUser, $permManageOwnOauthSessions); $authManager->addChild($roleAccountsWebUser, $permViewOwnOauthClients); $authManager->addChild($roleAccountsWebUser, $permManageOwnOauthClients); } diff --git a/console/migrations/m200925_224423_add_oauth_sessions_last_used_at_column.php b/console/migrations/m200925_224423_add_oauth_sessions_last_used_at_column.php new file mode 100644 index 0000000..4985d24 --- /dev/null +++ b/console/migrations/m200925_224423_add_oauth_sessions_last_used_at_column.php @@ -0,0 +1,19 @@ +addColumn('oauth_sessions', 'last_used_at', $this->integer(11)->unsigned()->notNull()); + $this->update('oauth_sessions', [ + 'last_used_at' => new Expression('`created_at`'), + ]); + } + + public function safeDown() { + $this->dropColumn('oauth_sessions', 'last_used_at'); + } + +}