From 2cc27d34adcf4ed85f7d04160191c29f0aa1b932 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 8 Dec 2024 16:54:45 +0100 Subject: [PATCH] Implemented device code grant --- .../controllers/AuthorizationController.php | 5 + api/modules/oauth/models/OauthProcess.php | 37 +++++- api/tests/functional/_steps/OauthSteps.php | 15 +++ ...{AuthCodeCest.php => CompleteFlowCest.php} | 61 +++++++--- api/tests/functional/oauth/DeviceCodeCest.php | 106 ++++++++++++++++++ api/tests/functional/oauth/ValidateCest.php | 50 +++++++-- .../OAuth2/AuthorizationServerFactory.php | 11 +- .../OAuth2/Entities/ClientEntity.php | 10 ++ .../OAuth2/Entities/DeviceCodeEntity.php | 40 +++++++ .../OAuth2/Grants/DeviceCodeGrant.php | 84 ++++++++++++++ .../OAuth2/Repositories/ClientRepository.php | 5 +- .../Repositories/DeviceCodeRepository.php | 73 ++++++++++++ .../ExtendedDeviceCodeRepositoryInterface.php | 16 +++ .../OAuth2/ResponseTypes/EmptyResponse.php | 15 +++ common/models/OauthDeviceCode.php | 47 ++++++++ common/models/amqp/AccountBanned.php | 14 --- common/models/amqp/AccountPardoned.php | 10 -- common/models/amqp/EmailChanged.php | 14 --- common/models/amqp/UsernameChanged.php | 14 --- common/tests/_support/FixtureHelper.php | 1 + .../tests/fixtures/OauthDeviceCodeFixture.php | 20 ++++ .../fixtures/data/oauth-device-codes.php | 15 +++ composer.json | 2 +- composer.lock | 25 +++-- console/db/Migration.php | 23 ++++ .../m241206_172929_oauth_device_codes.php | 36 ++++++ console/views/migration.php | 7 +- phpstan-baseline.neon | 80 ------------- 28 files changed, 665 insertions(+), 171 deletions(-) rename api/tests/functional/oauth/{AuthCodeCest.php => CompleteFlowCest.php} (79%) create mode 100644 api/tests/functional/oauth/DeviceCodeCest.php create mode 100644 common/components/OAuth2/Entities/DeviceCodeEntity.php create mode 100644 common/components/OAuth2/Grants/DeviceCodeGrant.php create mode 100644 common/components/OAuth2/Repositories/DeviceCodeRepository.php create mode 100644 common/components/OAuth2/Repositories/ExtendedDeviceCodeRepositoryInterface.php create mode 100644 common/components/OAuth2/ResponseTypes/EmptyResponse.php create mode 100644 common/models/OauthDeviceCode.php delete mode 100644 common/models/amqp/AccountBanned.php delete mode 100644 common/models/amqp/AccountPardoned.php delete mode 100644 common/models/amqp/EmailChanged.php delete mode 100644 common/models/amqp/UsernameChanged.php create mode 100644 common/tests/fixtures/OauthDeviceCodeFixture.php create mode 100644 common/tests/fixtures/data/oauth-device-codes.php create mode 100644 console/migrations/m241206_172929_oauth_device_codes.php diff --git a/api/modules/oauth/controllers/AuthorizationController.php b/api/modules/oauth/controllers/AuthorizationController.php index 03a51e6..9b5c986 100644 --- a/api/modules/oauth/controllers/AuthorizationController.php +++ b/api/modules/oauth/controllers/AuthorizationController.php @@ -50,6 +50,7 @@ final class AuthorizationController extends Controller { return [ 'validate' => ['GET'], 'complete' => ['POST'], + 'device' => ['POST'], 'token' => ['POST'], ]; } @@ -62,6 +63,10 @@ final class AuthorizationController extends Controller { return $this->oauthProcess->complete($this->getServerRequest()); } + public function actionDevice(): array { + return $this->oauthProcess->deviceCode($this->getServerRequest()); + } + public function actionToken(): array { return $this->oauthProcess->getToken($this->getServerRequest()); } diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index 85ce25f..7d0f24e 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -109,8 +109,10 @@ final readonly class OauthProcess { $result = [ 'success' => true, - 'redirectUri' => $response->getHeaderLine('Location'), ]; + if ($response->hasHeader('Location')) { + $result['redirectUri'] = $response->getHeaderLine('Location'); + } Yii::$app->statsd->inc('oauth.complete.success'); } catch (OAuthServerException $e) { @@ -125,6 +127,31 @@ final readonly class OauthProcess { return $result; } + /** + * @return array{ + * device_code: string, + * user_code: string, + * verification_uri: string, + * interval: int, + * expires_in: int, + * }|array{ + * error: string, + * message: string, + * } + */ + public function deviceCode(ServerRequestInterface $request): array { + try { + $response = $this->server->respondToDeviceAuthorizationRequest($request, new Response()); + } catch (OAuthServerException $e) { + Yii::$app->response->statusCode = $e->getHttpStatusCode(); + return $this->buildIssueErrorResponse($e); + } + + Yii::$app->statsd->inc('oauth.deviceCode.initialize'); + + return json_decode((string)$response->getBody(), true); + } + /** * The method is executed by the application server to which auth_token or refresh_token was given. * @@ -245,6 +272,7 @@ final readonly class OauthProcess { 'response_type', 'scope', 'state', + 'user_code', ])), 'client' => [ 'id' => $client->id, @@ -281,14 +309,19 @@ final readonly class OauthProcess { */ private function buildCompleteErrorResponse(OAuthServerException $e): array { $hint = $e->getPayload()['hint'] ?? ''; + $parameter = null; if (preg_match('/the `(\w+)` scope/', $hint, $matches)) { $parameter = $matches[1]; } + if ($parameter === null && str_starts_with($e->getErrorType(), 'invalid_')) { + $parameter = substr($e->getErrorType(), 8); // 8 is the length of the "invalid_" + } + $response = [ 'success' => false, 'error' => $e->getErrorType(), - 'parameter' => $parameter ?? null, + 'parameter' => $parameter, 'statusCode' => $e->getHttpStatusCode(), ]; diff --git a/api/tests/functional/_steps/OauthSteps.php b/api/tests/functional/_steps/OauthSteps.php index 4a22a89..475d551 100644 --- a/api/tests/functional/_steps/OauthSteps.php +++ b/api/tests/functional/_steps/OauthSteps.php @@ -8,6 +8,9 @@ use common\components\OAuth2\Repositories\PublicScopeRepository; class OauthSteps extends FunctionalTester { + /** + * @param string[] $permissions + */ public function obtainAuthCode(array $permissions = []): string { $this->amAuthenticated(); $this->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ @@ -23,6 +26,9 @@ class OauthSteps extends FunctionalTester { return $matches[1]; } + /** + * @param string[] $permissions + */ public function getAccessToken(array $permissions = []): string { $authCode = $this->obtainAuthCode($permissions); $response = $this->issueToken($authCode); @@ -30,6 +36,9 @@ class OauthSteps extends FunctionalTester { return $response['access_token']; } + /** + * @param string[] $permissions + */ public function getRefreshToken(array $permissions = []): string { $authCode = $this->obtainAuthCode(array_merge([PublicScopeRepository::OFFLINE_ACCESS], $permissions)); $response = $this->issueToken($authCode); @@ -37,6 +46,9 @@ class OauthSteps extends FunctionalTester { return $response['refresh_token']; } + /** + * @return array + */ public function issueToken(string $authCode): array { $this->sendPOST('/api/oauth2/v1/token', [ 'grant_type' => 'authorization_code', @@ -49,6 +61,9 @@ class OauthSteps extends FunctionalTester { return json_decode($this->grabResponse(), true); } + /** + * @param string[] $permissions + */ public function getAccessTokenByClientCredentialsGrant(array $permissions = [], bool $useTrusted = true): string { $this->sendPOST('/api/oauth2/v1/token', [ 'grant_type' => 'client_credentials', diff --git a/api/tests/functional/oauth/AuthCodeCest.php b/api/tests/functional/oauth/CompleteFlowCest.php similarity index 79% rename from api/tests/functional/oauth/AuthCodeCest.php rename to api/tests/functional/oauth/CompleteFlowCest.php index 397e7d6..f9d3208 100644 --- a/api/tests/functional/oauth/AuthCodeCest.php +++ b/api/tests/functional/oauth/CompleteFlowCest.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace api\tests\functional\oauth; use api\tests\FunctionalTester; +use Codeception\Attribute\Before; -class AuthCodeCest { +final class CompleteFlowCest { - public function completeSuccess(FunctionalTester $I): void { + public function successfullyCompleteAuthCodeFlow(FunctionalTester $I): void { $I->amAuthenticated(); - $I->wantTo('get auth code if I require some scope and pass accept field'); $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ 'client_id' => 'ely', 'redirect_uri' => 'http://ely.by', @@ -23,10 +23,20 @@ class AuthCodeCest { $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); } - /** - * @before completeSuccess - */ - public function completeSuccessWithLessScopes(FunctionalTester $I): void { + public function successfullyCompleteDeviceCodeFlow(FunctionalTester $I): void { + $I->amAuthenticated(); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'user_code' => 'AAAABBBB', + ]), ['accept' => true]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.redirectUri'); + } + + #[Before('successfullyCompleteAuthCodeFlow')] + public function successfullyCompleteAuthCodeFlowWithLessScopes(FunctionalTester $I): void { $I->amAuthenticated(); $I->wantTo('get auth code with less scopes as passed in the previous request without accept param'); $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ @@ -41,10 +51,8 @@ class AuthCodeCest { $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); } - /** - * @before completeSuccess - */ - public function completeSuccessWithSameScopes(FunctionalTester $I): void { + #[Before('successfullyCompleteAuthCodeFlow')] + public function successfullyCompleteAuthCodeFlowWithSameScopes(FunctionalTester $I): void { $I->amAuthenticated(); $I->wantTo('get auth code with the same scopes as passed in the previous request without accept param'); $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ @@ -119,9 +127,8 @@ class AuthCodeCest { ]); } - public function testCompleteActionWithDismissState(FunctionalTester $I): void { + public function completeAuthCodeFlowWithDecline(FunctionalTester $I): void { $I->amAuthenticated(); - $I->wantTo('get access_denied error if I pass accept in false state'); $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ 'client_id' => 'ely', 'redirect_uri' => 'http://ely.by', @@ -138,6 +145,34 @@ class AuthCodeCest { ]); } + public function completeDeviceCodeFlowWithDecline(FunctionalTester $I): void { + $I->amAuthenticated(); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'user_code' => 'AAAABBBB', + ]), ['accept' => false]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + } + + public function tryToCompleteAlreadyCompletedDeviceCodeFlow(FunctionalTester $I): void { + $I->amAuthenticated(); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'user_code' => 'AAAABBBB', + ]), ['accept' => true]); + $I->canSeeResponseCodeIs(200); + + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'user_code' => 'AAAABBBB', + ]), ['accept' => true]); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'used_user_code', + ]); + } + public function invalidClientId(FunctionalTester $I): void { $I->amAuthenticated(); $I->wantTo('check behavior on invalid client id'); diff --git a/api/tests/functional/oauth/DeviceCodeCest.php b/api/tests/functional/oauth/DeviceCodeCest.php new file mode 100644 index 0000000..504736f --- /dev/null +++ b/api/tests/functional/oauth/DeviceCodeCest.php @@ -0,0 +1,106 @@ +sendPOST('/api/oauth2/v1/device', [ + 'client_id' => 'ely', + 'scope' => 'account_info minecraft_server_session', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'verification_uri' => 'http://localhost/code', + 'interval' => 5, + 'expires_in' => 600, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.device_code'); + $I->canSeeResponseJsonMatchesJsonPath('$.user_code'); + } + + public function pollPendingDeviceCode(FunctionalTester $I): void { + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + 'client_id' => 'ely', + 'device_code' => 'nKuYFfwckZywqU8iUKv3ek4VtiMiMCkiC0YTZFPbWycSxdRpHiYP2wnv3S0KHBgYky8fRDqfhhCqzke7', + ]); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseContainsJson([ + 'error' => 'authorization_pending', + ]); + } + + /** + * @param Example $case + */ + #[Examples(true)] + #[Examples(false)] + public function finishFlowWithApprovedCode(FunctionalTester $I, Example $case): void { + // Initialize flow + $I->sendPOST('/api/oauth2/v1/device', [ + 'client_id' => 'ely', + 'scope' => 'account_info minecraft_server_session', + ]); + $I->canSeeResponseCodeIs(200); + + ['user_code' => $userCode, 'device_code' => $deviceCode] = json_decode($I->grabResponse(), true); + + // Approve device code by the user + $I->amAuthenticated(); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'user_code' => $userCode, + ]), ['accept' => $case[0]]); + $I->canSeeResponseCodeIs(200); + + // Finish flow by obtaining the access token + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + 'client_id' => 'ely', + 'device_code' => $deviceCode, + ]); + if ($case[0]) { + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'token_type' => 'Bearer', + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); + $I->cantSeeResponseJsonMatchesJsonPath('$.expires_in'); + $I->cantSeeResponseJsonMatchesJsonPath('$.refresh_token'); + } else { + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'access_denied', + 'message' => 'The resource owner or authorization server denied the request.', + ]); + } + } + + public function getAnErrorForUnknownClient(FunctionalTester $I): void { + $I->sendPOST('/api/oauth2/v1/device', [ + 'client_id' => 'invalid-client', + 'scope' => 'account_info minecraft_server_session', + ]); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_client', + ]); + } + + public function getAnErrorForInvalidScopes(FunctionalTester $I): void { + $I->sendPOST('/api/oauth2/v1/device', [ + 'client_id' => 'ely', + 'scope' => 'unknown-scope', + ]); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_scope', + ]); + } + +} diff --git a/api/tests/functional/oauth/ValidateCest.php b/api/tests/functional/oauth/ValidateCest.php index 8cf24d0..85a68cc 100644 --- a/api/tests/functional/oauth/ValidateCest.php +++ b/api/tests/functional/oauth/ValidateCest.php @@ -5,10 +5,9 @@ namespace api\tests\functional\oauth; use api\tests\FunctionalTester; -class ValidateCest { +final class ValidateCest { - public function completelyValidateValidRequest(FunctionalTester $I): void { - $I->wantTo('validate and obtain information about new oauth request'); + public function successfullyValidateRequestForAuthFlow(FunctionalTester $I): void { $I->sendGET('/api/oauth2/v1/validate', [ 'client_id' => 'ely', 'redirect_uri' => 'http://ely.by', @@ -41,7 +40,31 @@ class ValidateCest { ]); } - public function completelyValidateValidRequestWithOverriddenDescription(FunctionalTester $I): void { + public function successfullyValidateRequestForDeviceCode(FunctionalTester $I): void { + $I->sendGET('/api/oauth2/v1/validate', [ + 'user_code' => 'AAAABBBB', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => true, + 'oAuth' => [ + 'user_code' => 'AAAABBBB', + ], + 'client' => [ + 'id' => 'ely', + 'name' => 'Ely.by', + 'description' => 'Всем знакомое елуби', + ], + 'session' => [ + 'scopes' => [ + 'minecraft_server_session', + 'account_info', + ], + ], + ]); + } + + public function successfullyValidateRequestWithOverriddenDescriptionForAuthFlow(FunctionalTester $I): void { $I->wantTo('validate and get information with description replacement'); $I->sendGET('/api/oauth2/v1/validate', [ 'client_id' => 'ely', @@ -57,7 +80,7 @@ class ValidateCest { ]); } - public function unknownClientId(FunctionalTester $I): void { + public function unknownClientIdAuthFlow(FunctionalTester $I): void { $I->wantTo('check behavior on invalid client id'); $I->sendGET('/api/oauth2/v1/validate', [ 'client_id' => 'non-exists-client', @@ -72,7 +95,20 @@ class ValidateCest { ]); } - public function invalidScopes(FunctionalTester $I): void { + public function invalidCodeForDeviceCode(FunctionalTester $I): void { + $I->sendGET('/api/oauth2/v1/validate', [ + 'user_code' => 'XXXXXXXX', + ]); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'invalid_user_code', + 'parameter' => 'user_code', + 'statusCode' => 401, + ]); + } + + public function invalidScopesAuthFlow(FunctionalTester $I): void { $I->wantTo('check behavior on some invalid scopes'); $I->sendGET('/api/oauth2/v1/validate', [ 'client_id' => 'ely', @@ -91,7 +127,7 @@ class ValidateCest { $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); } - public function requestInternalScope(FunctionalTester $I): void { + public function requestInternalScopeAuthFlow(FunctionalTester $I): void { $I->wantTo('check behavior on request internal scope'); $I->sendGET('/api/oauth2/v1/validate', [ 'client_id' => 'ely', diff --git a/common/components/OAuth2/AuthorizationServerFactory.php b/common/components/OAuth2/AuthorizationServerFactory.php index fd2630e..06750e9 100644 --- a/common/components/OAuth2/AuthorizationServerFactory.php +++ b/common/components/OAuth2/AuthorizationServerFactory.php @@ -6,9 +6,9 @@ namespace common\components\OAuth2; use Carbon\CarbonInterval; use DateInterval; use League\OAuth2\Server\AuthorizationServer; -use yii\base\Component as BaseComponent; +use Yii; -final class AuthorizationServerFactory extends BaseComponent { +final class AuthorizationServerFactory { public static function build(): AuthorizationServer { $clientsRepo = new Repositories\ClientRepository(); @@ -17,6 +17,7 @@ final class AuthorizationServerFactory extends BaseComponent { $internalScopesRepo = new Repositories\InternalScopeRepository(); $authCodesRepo = new Repositories\AuthCodeRepository(); $refreshTokensRepo = new Repositories\RefreshTokenRepository(); + $deviceCodesRepo = new Repositories\DeviceCodeRepository(); $accessTokenTTL = CarbonInterval::create(-1); // Set negative value to make tokens non expiring @@ -42,6 +43,12 @@ final class AuthorizationServerFactory extends BaseComponent { $authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL); $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling + $verificationUri = Yii::$app->request->getHostInfo() . '/code'; + $deviceCodeGrant = new Grants\DeviceCodeGrant($deviceCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'), $verificationUri); + $deviceCodeGrant->setIntervalVisibility(true); + $authServer->enableGrantType($deviceCodeGrant, $accessTokenTTL); + $deviceCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling + return $authServer; } diff --git a/common/components/OAuth2/Entities/ClientEntity.php b/common/components/OAuth2/Entities/ClientEntity.php index 5ea23ec..8a53a8b 100644 --- a/common/components/OAuth2/Entities/ClientEntity.php +++ b/common/components/OAuth2/Entities/ClientEntity.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace common\components\OAuth2\Entities; +use common\models\OauthClient; use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\Traits\ClientTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait; @@ -26,6 +27,15 @@ final class ClientEntity implements ClientEntityInterface { $this->redirectUri = $redirectUri; } + public static function fromModel(OauthClient $model): self { + return new self( + $model->id, // @phpstan-ignore argument.type + $model->name, + $model->redirect_uri ?: '', + (bool)$model->is_trusted, + ); + } + public function isConfidential(): bool { return true; } diff --git a/common/components/OAuth2/Entities/DeviceCodeEntity.php b/common/components/OAuth2/Entities/DeviceCodeEntity.php new file mode 100644 index 0000000..a547927 --- /dev/null +++ b/common/components/OAuth2/Entities/DeviceCodeEntity.php @@ -0,0 +1,40 @@ +setIdentifier($model->device_code); // @phpstan-ignore argument.type + $entity->setUserCode($model->user_code); + $entity->setClient(ClientEntity::fromModel($model->client)); + $entity->setExpiryDateTime(CarbonImmutable::createFromTimestampUTC($model->expires_at)); + foreach ($model->scopes as $scope) { + $entity->addScope(new ScopeEntity($scope)); + } + + if ($model->account_id !== null) { + $entity->setUserIdentifier((string)$model->account_id); + $entity->setUserApproved((bool)$model->is_approved === true); + } + + if ($model->last_polled_at !== null) { + $entity->setLastPolledAt(CarbonImmutable::createFromTimestampUTC($model->last_polled_at)); + } + + return $entity; + } + +} diff --git a/common/components/OAuth2/Grants/DeviceCodeGrant.php b/common/components/OAuth2/Grants/DeviceCodeGrant.php new file mode 100644 index 0000000..4fff6a0 --- /dev/null +++ b/common/components/OAuth2/Grants/DeviceCodeGrant.php @@ -0,0 +1,84 @@ +getQueryParams()['user_code']); + } + + /** + * @throws \League\OAuth2\Server\Exception\OAuthServerException + */ + public function validateAuthorizationRequest(ServerRequestInterface $request): AuthorizationRequestInterface { + $userCode = $this->getQueryStringParameter('user_code', $request); + if ($userCode === null) { + throw OAuthServerException::invalidRequest('user_code'); + } + + $deviceCode = $this->deviceCodeRepository->getDeviceCodeEntityByUserCode($userCode); + if ($deviceCode === null) { + throw new OAuthServerException('Unknown user code', 4, 'invalid_user_code', 401); + } + + if ($deviceCode->getUserIdentifier() !== null) { + throw new OAuthServerException('The user code has already been used', 6, 'used_user_code', 400); + } + + $authorizationRequest = new AuthorizationRequest(); + $authorizationRequest->setGrantTypeId($this->getIdentifier()); + $authorizationRequest->setClient($deviceCode->getClient()); + $authorizationRequest->setScopes($deviceCode->getScopes()); + // We need the device code during the "completeAuthorizationRequest" implementation, so store it inside some unused field. + // Perfectly the implementation must rely on the "user code" but library's implementation built on top of the "device code". + $authorizationRequest->setCodeChallenge($deviceCode->getIdentifier()); + + return $authorizationRequest; + } + + /** + * @throws \League\OAuth2\Server\Exception\OAuthServerException + */ + public function completeAuthorizationRequest(AuthorizationRequestInterface $authorizationRequest): ResponseTypeInterface { + $this->completeDeviceAuthorizationRequest( + $authorizationRequest->getCodeChallenge(), + $authorizationRequest->getUser()->getIdentifier(), + $authorizationRequest->isAuthorizationApproved(), + ); + + return new EmptyResponse(); + } + +} diff --git a/common/components/OAuth2/Repositories/ClientRepository.php b/common/components/OAuth2/Repositories/ClientRepository.php index 859fcfa..e791c62 100644 --- a/common/components/OAuth2/Repositories/ClientRepository.php +++ b/common/components/OAuth2/Repositories/ClientRepository.php @@ -16,8 +16,7 @@ final class ClientRepository implements ClientRepositoryInterface { return null; } - // @phpstan-ignore argument.type - return new ClientEntity($client->id, $client->name, $client->redirect_uri ?: '', (bool)$client->is_trusted); + return ClientEntity::fromModel($client); } public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool { @@ -30,7 +29,7 @@ final class ClientRepository implements ClientRepositoryInterface { return false; } - if ($clientSecret !== null && $clientSecret !== $client->secret) { + if (!empty($clientSecret) && $clientSecret !== $client->secret) { return false; } diff --git a/common/components/OAuth2/Repositories/DeviceCodeRepository.php b/common/components/OAuth2/Repositories/DeviceCodeRepository.php new file mode 100644 index 0000000..37a0d26 --- /dev/null +++ b/common/components/OAuth2/Repositories/DeviceCodeRepository.php @@ -0,0 +1,73 @@ +findModelByDeviceCode($deviceCodeEntity->getIdentifier()) ?? new OauthDeviceCode(); + $model->device_code = $deviceCodeEntity->getIdentifier(); + $model->user_code = $deviceCodeEntity->getUserCode(); + $model->client_id = $deviceCodeEntity->getClient()->getIdentifier(); + $model->scopes = array_map(fn($scope) => $scope->getIdentifier(), $deviceCodeEntity->getScopes()); + $model->last_polled_at = $deviceCodeEntity->getLastPolledAt()?->getTimestamp(); + $model->expires_at = $deviceCodeEntity->getExpiryDateTime()->getTimestamp(); + if ($deviceCodeEntity->getUserIdentifier() !== null) { + $model->account_id = (int)$deviceCodeEntity->getUserIdentifier(); + $model->is_approved = $deviceCodeEntity->getUserApproved(); + } + + try { + Assert::true($model->save()); + } catch (Exception $e) { + if (str_contains($e->getMessage(), 'duplicate')) { + throw UniqueTokenIdentifierConstraintViolationException::create(); + } + + throw $e; + } + } + + public function getDeviceCodeEntityByDeviceCode(string $deviceCodeEntity): ?DeviceCodeEntityInterface { + $model = $this->findModelByDeviceCode($deviceCodeEntity); + if ($model === null) { + return null; + } + + return DeviceCodeEntity::fromModel($model); + } + + public function getDeviceCodeEntityByUserCode(string $userCode): ?DeviceCodeEntityInterface { + $model = OauthDeviceCode::findOne(['user_code' => $userCode]); + if ($model === null) { + return null; + } + + return DeviceCodeEntity::fromModel($model); + } + + public function revokeDeviceCode(string $codeId): void { + $this->findModelByDeviceCode($codeId)?->delete(); + } + + public function isDeviceCodeRevoked(string $codeId): bool { + return $this->findModelByDeviceCode($codeId) === null; + } + + private function findModelByDeviceCode(string $deviceCode): ?OauthDeviceCode { + return OauthDeviceCode::findOne(['device_code' => $deviceCode]); + } + +} diff --git a/common/components/OAuth2/Repositories/ExtendedDeviceCodeRepositoryInterface.php b/common/components/OAuth2/Repositories/ExtendedDeviceCodeRepositoryInterface.php new file mode 100644 index 0000000..e468348 --- /dev/null +++ b/common/components/OAuth2/Repositories/ExtendedDeviceCodeRepositoryInterface.php @@ -0,0 +1,16 @@ + AttributeTypecastBehavior::class, + 'attributeTypes' => [ + 'is_approved' => AttributeTypecastBehavior::TYPE_BOOLEAN, + ], + 'typecastAfterSave' => true, + 'typecastAfterFind' => true, + ], + ]; + } + + public function getClient(): OauthClientQuery { + /** @noinspection PhpIncompatibleReturnTypeInspection */ + return $this->hasOne(OauthClient::class, ['id' => 'client_id']); + } + +} diff --git a/common/models/amqp/AccountBanned.php b/common/models/amqp/AccountBanned.php deleted file mode 100644 index b612c34..0000000 --- a/common/models/amqp/AccountBanned.php +++ /dev/null @@ -1,14 +0,0 @@ - fixtures\UsernameHistoryFixture::class, 'oauthClients' => fixtures\OauthClientFixture::class, 'oauthSessions' => fixtures\OauthSessionFixture::class, + 'oauthDeviceCodes' => fixtures\OauthDeviceCodeFixture::class, 'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class, 'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class, 'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class, diff --git a/common/tests/fixtures/OauthDeviceCodeFixture.php b/common/tests/fixtures/OauthDeviceCodeFixture.php new file mode 100644 index 0000000..38d2ec1 --- /dev/null +++ b/common/tests/fixtures/OauthDeviceCodeFixture.php @@ -0,0 +1,20 @@ + 'nKuYFfwckZywqU8iUKv3ek4VtiMiMCkiC0YTZFPbWycSxdRpHiYP2wnv3S0KHBgYky8fRDqfhhCqzke7', + 'user_code' => 'AAAABBBB', + 'client_id' => 'ely', + 'scopes' => ['minecraft_server_session', 'account_info'], + 'account_id' => null, + 'is_approved' => null, + 'last_polled_at' => null, + 'expires_at' => time() + 1800, + ], +]; diff --git a/composer.json b/composer.json index 69dd842..d0bc439 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "erickskrauch/phpstan-yii2": "dev-master", "guzzlehttp/guzzle": "^6|^7", "lcobucci/jwt": "^5.4", - "league/oauth2-server": "^9.1.0", + "league/oauth2-server": "dev-master#03dcdd7 as 9.2.0", "nesbot/carbon": "^3", "nohnaimer/yii2-sentry": "^2.0", "paragonie/constant_time_encoding": "^3", diff --git a/composer.lock b/composer.lock index b6e7fa9..acb4321 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1b49f881a8b10f52645cc0b04cf58bf3", + "content-hash": "b50434a13836bd5adf5d0083e8be7d73", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1450,16 +1450,16 @@ }, { "name": "league/oauth2-server", - "version": "9.1.0", + "version": "dev-master", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-server.git", - "reference": "d511107cb018ead0bd84f86402b086306738c686" + "reference": "03dcdd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/d511107cb018ead0bd84f86402b086306738c686", - "reference": "d511107cb018ead0bd84f86402b086306738c686", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/03dcdd7", + "reference": "03dcdd7", "shasum": "" }, "require": { @@ -1491,6 +1491,7 @@ "slevomat/coding-standard": "^8.14.1", "squizlabs/php_codesniffer": "^3.8" }, + "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -1534,7 +1535,7 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth2-server/issues", - "source": "https://github.com/thephpleague/oauth2-server/tree/9.1.0" + "source": "https://github.com/thephpleague/oauth2-server/tree/master" }, "funding": [ { @@ -1542,7 +1543,7 @@ "type": "github" } ], - "time": "2024-11-21T22:47:09+00:00" + "time": "2024-11-25T19:29:16+00:00" }, { "name": "league/uri", @@ -10862,11 +10863,19 @@ "time": "2024-03-03T12:36:25+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "league/oauth2-server", + "version": "9999999-dev", + "alias": "9.2.0", + "alias_normalized": "9.2.0.0" + } + ], "minimum-stability": "stable", "stability-flags": { "ely/yii2-tempmail-validator": 20, "erickskrauch/phpstan-yii2": 20, + "league/oauth2-server": 20, "roave/security-advisories": 20 }, "prefer-stable": false, diff --git a/console/db/Migration.php b/console/db/Migration.php index fb9401c..ed2ca38 100644 --- a/console/db/Migration.php +++ b/console/db/Migration.php @@ -1,6 +1,9 @@ |null $columns + */ public function createTable($table, $columns, $options = null): void { if ($options === null) { $options = $this->getTableOptions(); @@ -34,4 +40,21 @@ class Migration extends YiiMigration { return ' PRIMARY KEY (' . implode(', ', $columns) . ') '; } + protected function getPrimaryKeyType(string $table, bool $nullable = false): string { + $primaryKeys = $this->db->getTableSchema($table)->primaryKey; + if (count($primaryKeys) === 0) { + throw new Exception("The table \"{$table}\" have no primary keys."); + } + + if (count($primaryKeys) > 1) { + throw new Exception("The table \"{$table}\" have more than one primary key."); + } + + return $this->getColumnType($table, $primaryKeys[0], $nullable); + } + + protected function getColumnType(string $table, string $column, bool $nullable = false): string { + return $this->db->getTableSchema($table)->getColumn($column)->dbType . ($nullable ? '' : ' NOT NULL'); + } + } diff --git a/console/migrations/m241206_172929_oauth_device_codes.php b/console/migrations/m241206_172929_oauth_device_codes.php new file mode 100644 index 0000000..7791a7a --- /dev/null +++ b/console/migrations/m241206_172929_oauth_device_codes.php @@ -0,0 +1,36 @@ +createTable('oauth_device_codes', [ + 'device_code' => $this->string(96)->notNull(), + 'user_code' => $this->string(16)->notNull(), + 'client_id' => $this->getPrimaryKeyType('oauth_clients'), + 'scopes' => $this->json()->notNull()->toString('scopes'), + 'account_id' => $this->getPrimaryKeyType('accounts', true), + 'is_approved' => $this->boolean()->unsigned(), + 'last_polled_at' => $this->integer(11)->unsigned(), + 'expires_at' => $this->integer(11)->unsigned()->notNull(), + $this->primary('device_code'), + ]); + $this->createIndex('user_code', 'oauth_device_codes', 'user_code', true); + $this->createIndex('expires_in', 'oauth_device_codes', 'expires_at'); + $this->addForeignKey('FK_oauth_device_code_to_oauth_client', 'oauth_device_codes', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE'); + $this->addForeignKey('FK_oauth_device_code_to_account', 'oauth_device_codes', 'account_id', 'accounts', 'id', 'CASCADE', 'CASCADE'); + $this->execute(' + CREATE EVENT oauth_device_codes_cleanup + ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 HOUR + DO DELETE FROM oauth_device_codes WHERE expires_at < UNIX_TIMESTAMP() + '); + } + + public function safeDown(): void { + $this->execute('DROP EVENT oauth_device_codes_cleanup'); + $this->dropTable('oauth_device_codes'); + } + +} diff --git a/console/views/migration.php b/console/views/migration.php index b8bef4d..1e1438f 100644 --- a/console/views/migration.php +++ b/console/views/migration.php @@ -5,16 +5,17 @@ echo " +declare(strict_types=1); use console\db\Migration; -class extends Migration { +final class extends Migration { - public function safeUp() { + public function safeUp(): void { } - public function safeDown() { + public function safeDown(): void { } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index fa9d25c..8d865fc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -810,31 +810,6 @@ parameters: count: 1 path: api/tests/functional/_steps/AuthserverSteps.php - - - message: "#^Method api\\\\tests\\\\functional\\\\_steps\\\\OauthSteps\\:\\:getAccessToken\\(\\) has parameter \\$permissions with no value type specified in iterable type array\\.$#" - count: 1 - path: api/tests/functional/_steps/OauthSteps.php - - - - message: "#^Method api\\\\tests\\\\functional\\\\_steps\\\\OauthSteps\\:\\:getAccessTokenByClientCredentialsGrant\\(\\) has parameter \\$permissions with no value type specified in iterable type array\\.$#" - count: 1 - path: api/tests/functional/_steps/OauthSteps.php - - - - message: "#^Method api\\\\tests\\\\functional\\\\_steps\\\\OauthSteps\\:\\:getRefreshToken\\(\\) has parameter \\$permissions with no value type specified in iterable type array\\.$#" - count: 1 - path: api/tests/functional/_steps/OauthSteps.php - - - - message: "#^Method api\\\\tests\\\\functional\\\\_steps\\\\OauthSteps\\:\\:issueToken\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: api/tests/functional/_steps/OauthSteps.php - - - - message: "#^Method api\\\\tests\\\\functional\\\\_steps\\\\OauthSteps\\:\\:obtainAuthCode\\(\\) has parameter \\$permissions with no value type specified in iterable type array\\.$#" - count: 1 - path: api/tests/functional/_steps/OauthSteps.php - - message: "#^Offset 1 does not exist on array\\{0\\?\\: string, 1\\?\\: non\\-empty\\-string\\}\\.$#" count: 1 @@ -1430,56 +1405,6 @@ parameters: count: 1 path: common/models/UsernameHistory.php - - - message: "#^Property common\\\\models\\\\amqp\\\\AccountBanned\\:\\:\\$accountId has no type specified\\.$#" - count: 1 - path: common/models/amqp/AccountBanned.php - - - - message: "#^Property common\\\\models\\\\amqp\\\\AccountBanned\\:\\:\\$duration has no type specified\\.$#" - count: 1 - path: common/models/amqp/AccountBanned.php - - - - message: "#^Property common\\\\models\\\\amqp\\\\AccountBanned\\:\\:\\$message has no type specified\\.$#" - count: 1 - path: common/models/amqp/AccountBanned.php - - - - message: "#^Property common\\\\models\\\\amqp\\\\AccountPardoned\\:\\:\\$accountId has no type specified\\.$#" - count: 1 - path: common/models/amqp/AccountPardoned.php - - - - message: "#^Property common\\\\models\\\\amqp\\\\EmailChanged\\:\\:\\$accountId has no type specified\\.$#" - count: 1 - path: common/models/amqp/EmailChanged.php - - - - message: "#^Property common\\\\models\\\\amqp\\\\EmailChanged\\:\\:\\$newEmail has no type specified\\.$#" - count: 1 - path: common/models/amqp/EmailChanged.php - - - - message: "#^Property common\\\\models\\\\amqp\\\\EmailChanged\\:\\:\\$oldEmail has no type specified\\.$#" - count: 1 - path: common/models/amqp/EmailChanged.php - - - - message: "#^Property common\\\\models\\\\amqp\\\\UsernameChanged\\:\\:\\$accountId has no type specified\\.$#" - count: 1 - path: common/models/amqp/UsernameChanged.php - - - - message: "#^Property common\\\\models\\\\amqp\\\\UsernameChanged\\:\\:\\$newUsername has no type specified\\.$#" - count: 1 - path: common/models/amqp/UsernameChanged.php - - - - message: "#^Property common\\\\models\\\\amqp\\\\UsernameChanged\\:\\:\\$oldUsername has no type specified\\.$#" - count: 1 - path: common/models/amqp/UsernameChanged.php - - message: "#^Method common\\\\notifications\\\\AccountDeletedNotification\\:\\:getPayloads\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 @@ -1880,11 +1805,6 @@ parameters: count: 1 path: common/validators/UsernameValidator.php - - - message: "#^Method console\\\\db\\\\Migration\\:\\:createTable\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#" - count: 1 - path: console/db/Migration.php - - message: "#^Return type \\(void\\) of method m130524_201442_init\\:\\:down\\(\\) should be compatible with return type \\(bool\\) of method yii\\\\db\\\\MigrationInterface\\:\\:down\\(\\)$#" count: 1