mirror of
				https://github.com/elyby/accounts.git
				synced 2025-05-31 14:11:46 +05:30 
			
		
		
		
	Implemented device code grant
This commit is contained in:
		| @@ -50,6 +50,7 @@ final class AuthorizationController extends Controller { | |||||||
|         return [ |         return [ | ||||||
|             'validate' => ['GET'], |             'validate' => ['GET'], | ||||||
|             'complete' => ['POST'], |             'complete' => ['POST'], | ||||||
|  |             'device' => ['POST'], | ||||||
|             'token' => ['POST'], |             'token' => ['POST'], | ||||||
|         ]; |         ]; | ||||||
|     } |     } | ||||||
| @@ -62,6 +63,10 @@ final class AuthorizationController extends Controller { | |||||||
|         return $this->oauthProcess->complete($this->getServerRequest()); |         return $this->oauthProcess->complete($this->getServerRequest()); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function actionDevice(): array { | ||||||
|  |         return $this->oauthProcess->deviceCode($this->getServerRequest()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public function actionToken(): array { |     public function actionToken(): array { | ||||||
|         return $this->oauthProcess->getToken($this->getServerRequest()); |         return $this->oauthProcess->getToken($this->getServerRequest()); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -109,8 +109,10 @@ final readonly class OauthProcess { | |||||||
|  |  | ||||||
|             $result = [ |             $result = [ | ||||||
|                 'success' => true, |                 'success' => true, | ||||||
|                 'redirectUri' => $response->getHeaderLine('Location'), |  | ||||||
|             ]; |             ]; | ||||||
|  |             if ($response->hasHeader('Location')) { | ||||||
|  |                 $result['redirectUri'] = $response->getHeaderLine('Location'); | ||||||
|  |             } | ||||||
|  |  | ||||||
|             Yii::$app->statsd->inc('oauth.complete.success'); |             Yii::$app->statsd->inc('oauth.complete.success'); | ||||||
|         } catch (OAuthServerException $e) { |         } catch (OAuthServerException $e) { | ||||||
| @@ -125,6 +127,31 @@ final readonly class OauthProcess { | |||||||
|         return $result; |         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. |      * 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', |                 'response_type', | ||||||
|                 'scope', |                 'scope', | ||||||
|                 'state', |                 'state', | ||||||
|  |                 'user_code', | ||||||
|             ])), |             ])), | ||||||
|             'client' => [ |             'client' => [ | ||||||
|                 'id' => $client->id, |                 'id' => $client->id, | ||||||
| @@ -281,14 +309,19 @@ final readonly class OauthProcess { | |||||||
|      */ |      */ | ||||||
|     private function buildCompleteErrorResponse(OAuthServerException $e): array { |     private function buildCompleteErrorResponse(OAuthServerException $e): array { | ||||||
|         $hint = $e->getPayload()['hint'] ?? ''; |         $hint = $e->getPayload()['hint'] ?? ''; | ||||||
|  |         $parameter = null; | ||||||
|         if (preg_match('/the `(\w+)` scope/', $hint, $matches)) { |         if (preg_match('/the `(\w+)` scope/', $hint, $matches)) { | ||||||
|             $parameter = $matches[1]; |             $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 = [ |         $response = [ | ||||||
|             'success' => false, |             'success' => false, | ||||||
|             'error' => $e->getErrorType(), |             'error' => $e->getErrorType(), | ||||||
|             'parameter' => $parameter ?? null, |             'parameter' => $parameter, | ||||||
|             'statusCode' => $e->getHttpStatusCode(), |             'statusCode' => $e->getHttpStatusCode(), | ||||||
|         ]; |         ]; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -8,6 +8,9 @@ use common\components\OAuth2\Repositories\PublicScopeRepository; | |||||||
|  |  | ||||||
| class OauthSteps extends FunctionalTester { | class OauthSteps extends FunctionalTester { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @param string[] $permissions | ||||||
|  |      */ | ||||||
|     public function obtainAuthCode(array $permissions = []): string { |     public function obtainAuthCode(array $permissions = []): string { | ||||||
|         $this->amAuthenticated(); |         $this->amAuthenticated(); | ||||||
|         $this->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ |         $this->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ | ||||||
| @@ -23,6 +26,9 @@ class OauthSteps extends FunctionalTester { | |||||||
|         return $matches[1]; |         return $matches[1]; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @param string[] $permissions | ||||||
|  |      */ | ||||||
|     public function getAccessToken(array $permissions = []): string { |     public function getAccessToken(array $permissions = []): string { | ||||||
|         $authCode = $this->obtainAuthCode($permissions); |         $authCode = $this->obtainAuthCode($permissions); | ||||||
|         $response = $this->issueToken($authCode); |         $response = $this->issueToken($authCode); | ||||||
| @@ -30,6 +36,9 @@ class OauthSteps extends FunctionalTester { | |||||||
|         return $response['access_token']; |         return $response['access_token']; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @param string[] $permissions | ||||||
|  |      */ | ||||||
|     public function getRefreshToken(array $permissions = []): string { |     public function getRefreshToken(array $permissions = []): string { | ||||||
|         $authCode = $this->obtainAuthCode(array_merge([PublicScopeRepository::OFFLINE_ACCESS], $permissions)); |         $authCode = $this->obtainAuthCode(array_merge([PublicScopeRepository::OFFLINE_ACCESS], $permissions)); | ||||||
|         $response = $this->issueToken($authCode); |         $response = $this->issueToken($authCode); | ||||||
| @@ -37,6 +46,9 @@ class OauthSteps extends FunctionalTester { | |||||||
|         return $response['refresh_token']; |         return $response['refresh_token']; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @return array<string, mixed> | ||||||
|  |      */ | ||||||
|     public function issueToken(string $authCode): array { |     public function issueToken(string $authCode): array { | ||||||
|         $this->sendPOST('/api/oauth2/v1/token', [ |         $this->sendPOST('/api/oauth2/v1/token', [ | ||||||
|             'grant_type' => 'authorization_code', |             'grant_type' => 'authorization_code', | ||||||
| @@ -49,6 +61,9 @@ class OauthSteps extends FunctionalTester { | |||||||
|         return json_decode($this->grabResponse(), true); |         return json_decode($this->grabResponse(), true); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @param string[] $permissions | ||||||
|  |      */ | ||||||
|     public function getAccessTokenByClientCredentialsGrant(array $permissions = [], bool $useTrusted = true): string { |     public function getAccessTokenByClientCredentialsGrant(array $permissions = [], bool $useTrusted = true): string { | ||||||
|         $this->sendPOST('/api/oauth2/v1/token', [ |         $this->sendPOST('/api/oauth2/v1/token', [ | ||||||
|             'grant_type' => 'client_credentials', |             'grant_type' => 'client_credentials', | ||||||
|   | |||||||
| @@ -4,12 +4,12 @@ declare(strict_types=1); | |||||||
| namespace api\tests\functional\oauth; | namespace api\tests\functional\oauth; | ||||||
| 
 | 
 | ||||||
| use api\tests\FunctionalTester; | 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->amAuthenticated(); | ||||||
|         $I->wantTo('get auth code if I require some scope and pass accept field'); |  | ||||||
|         $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ |         $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ | ||||||
|             'client_id' => 'ely', |             'client_id' => 'ely', | ||||||
|             'redirect_uri' => 'http://ely.by', |             'redirect_uri' => 'http://ely.by', | ||||||
| @@ -23,10 +23,20 @@ class AuthCodeCest { | |||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); |         $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     public function successfullyCompleteDeviceCodeFlow(FunctionalTester $I): void { | ||||||
|      * @before completeSuccess |         $I->amAuthenticated(); | ||||||
|      */ |         $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ | ||||||
|     public function completeSuccessWithLessScopes(FunctionalTester $I): void { |             '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->amAuthenticated(); | ||||||
|         $I->wantTo('get auth code with less scopes as passed in the previous request without accept param'); |         $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([ |         $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ | ||||||
| @@ -41,10 +51,8 @@ class AuthCodeCest { | |||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); |         $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     #[Before('successfullyCompleteAuthCodeFlow')]
 | ||||||
|      * @before completeSuccess |     public function successfullyCompleteAuthCodeFlowWithSameScopes(FunctionalTester $I): void { | ||||||
|      */ |  | ||||||
|     public function completeSuccessWithSameScopes(FunctionalTester $I): void { |  | ||||||
|         $I->amAuthenticated(); |         $I->amAuthenticated(); | ||||||
|         $I->wantTo('get auth code with the same scopes as passed in the previous request without accept param'); |         $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([ |         $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->amAuthenticated(); | ||||||
|         $I->wantTo('get access_denied error if I pass accept in false state'); |  | ||||||
|         $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ |         $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ | ||||||
|             'client_id' => 'ely', |             'client_id' => 'ely', | ||||||
|             'redirect_uri' => 'http://ely.by', |             '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 { |     public function invalidClientId(FunctionalTester $I): void { | ||||||
|         $I->amAuthenticated(); |         $I->amAuthenticated(); | ||||||
|         $I->wantTo('check behavior on invalid client id'); |         $I->wantTo('check behavior on invalid client id'); | ||||||
							
								
								
									
										106
									
								
								api/tests/functional/oauth/DeviceCodeCest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								api/tests/functional/oauth/DeviceCodeCest.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | namespace api\tests\functional\oauth; | ||||||
|  |  | ||||||
|  | use api\tests\FunctionalTester; | ||||||
|  | use Codeception\Attribute\Examples; | ||||||
|  | use Codeception\Example; | ||||||
|  |  | ||||||
|  | final class DeviceCodeCest { | ||||||
|  |  | ||||||
|  |     public function initiateFlow(FunctionalTester $I): void { | ||||||
|  |         $I->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<array{boolean}> $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', | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -5,10 +5,9 @@ namespace api\tests\functional\oauth; | |||||||
|  |  | ||||||
| use api\tests\FunctionalTester; | use api\tests\FunctionalTester; | ||||||
|  |  | ||||||
| class ValidateCest { | final class ValidateCest { | ||||||
|  |  | ||||||
|     public function completelyValidateValidRequest(FunctionalTester $I): void { |     public function successfullyValidateRequestForAuthFlow(FunctionalTester $I): void { | ||||||
|         $I->wantTo('validate and obtain information about new oauth request'); |  | ||||||
|         $I->sendGET('/api/oauth2/v1/validate', [ |         $I->sendGET('/api/oauth2/v1/validate', [ | ||||||
|             'client_id' => 'ely', |             'client_id' => 'ely', | ||||||
|             'redirect_uri' => 'http://ely.by', |             '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->wantTo('validate and get information with description replacement'); | ||||||
|         $I->sendGET('/api/oauth2/v1/validate', [ |         $I->sendGET('/api/oauth2/v1/validate', [ | ||||||
|             'client_id' => 'ely', |             '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->wantTo('check behavior on invalid client id'); | ||||||
|         $I->sendGET('/api/oauth2/v1/validate', [ |         $I->sendGET('/api/oauth2/v1/validate', [ | ||||||
|             'client_id' => 'non-exists-client', |             '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->wantTo('check behavior on some invalid scopes'); | ||||||
|         $I->sendGET('/api/oauth2/v1/validate', [ |         $I->sendGET('/api/oauth2/v1/validate', [ | ||||||
|             'client_id' => 'ely', |             'client_id' => 'ely', | ||||||
| @@ -91,7 +127,7 @@ class ValidateCest { | |||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); |         $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function requestInternalScope(FunctionalTester $I): void { |     public function requestInternalScopeAuthFlow(FunctionalTester $I): void { | ||||||
|         $I->wantTo('check behavior on request internal scope'); |         $I->wantTo('check behavior on request internal scope'); | ||||||
|         $I->sendGET('/api/oauth2/v1/validate', [ |         $I->sendGET('/api/oauth2/v1/validate', [ | ||||||
|             'client_id' => 'ely', |             'client_id' => 'ely', | ||||||
|   | |||||||
| @@ -6,9 +6,9 @@ namespace common\components\OAuth2; | |||||||
| use Carbon\CarbonInterval; | use Carbon\CarbonInterval; | ||||||
| use DateInterval; | use DateInterval; | ||||||
| use League\OAuth2\Server\AuthorizationServer; | 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 { |     public static function build(): AuthorizationServer { | ||||||
|         $clientsRepo = new Repositories\ClientRepository(); |         $clientsRepo = new Repositories\ClientRepository(); | ||||||
| @@ -17,6 +17,7 @@ final class AuthorizationServerFactory extends BaseComponent { | |||||||
|         $internalScopesRepo = new Repositories\InternalScopeRepository(); |         $internalScopesRepo = new Repositories\InternalScopeRepository(); | ||||||
|         $authCodesRepo = new Repositories\AuthCodeRepository(); |         $authCodesRepo = new Repositories\AuthCodeRepository(); | ||||||
|         $refreshTokensRepo = new Repositories\RefreshTokenRepository(); |         $refreshTokensRepo = new Repositories\RefreshTokenRepository(); | ||||||
|  |         $deviceCodesRepo = new Repositories\DeviceCodeRepository(); | ||||||
|  |  | ||||||
|         $accessTokenTTL = CarbonInterval::create(-1); // Set negative value to make tokens non expiring |         $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); |         $authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL); | ||||||
|         $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling |         $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; |         return $authServer; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ declare(strict_types=1); | |||||||
|  |  | ||||||
| namespace common\components\OAuth2\Entities; | namespace common\components\OAuth2\Entities; | ||||||
|  |  | ||||||
|  | use common\models\OauthClient; | ||||||
| use League\OAuth2\Server\Entities\ClientEntityInterface; | use League\OAuth2\Server\Entities\ClientEntityInterface; | ||||||
| use League\OAuth2\Server\Entities\Traits\ClientTrait; | use League\OAuth2\Server\Entities\Traits\ClientTrait; | ||||||
| use League\OAuth2\Server\Entities\Traits\EntityTrait; | use League\OAuth2\Server\Entities\Traits\EntityTrait; | ||||||
| @@ -26,6 +27,15 @@ final class ClientEntity implements ClientEntityInterface { | |||||||
|         $this->redirectUri = $redirectUri; |         $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 { |     public function isConfidential(): bool { | ||||||
|         return true; |         return true; | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										40
									
								
								common/components/OAuth2/Entities/DeviceCodeEntity.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								common/components/OAuth2/Entities/DeviceCodeEntity.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | namespace common\components\OAuth2\Entities; | ||||||
|  |  | ||||||
|  | use Carbon\CarbonImmutable; | ||||||
|  | use common\models\OauthDeviceCode; | ||||||
|  | use League\OAuth2\Server\Entities\DeviceCodeEntityInterface; | ||||||
|  | use League\OAuth2\Server\Entities\Traits\DeviceCodeTrait; | ||||||
|  | use League\OAuth2\Server\Entities\Traits\EntityTrait; | ||||||
|  | use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; | ||||||
|  |  | ||||||
|  | final class DeviceCodeEntity implements DeviceCodeEntityInterface { | ||||||
|  |     use EntityTrait; | ||||||
|  |     use TokenEntityTrait; | ||||||
|  |     use DeviceCodeTrait; | ||||||
|  |  | ||||||
|  |     public static function fromModel(OauthDeviceCode $model): self { | ||||||
|  |         $entity = new self(); | ||||||
|  |         $entity->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; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										84
									
								
								common/components/OAuth2/Grants/DeviceCodeGrant.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								common/components/OAuth2/Grants/DeviceCodeGrant.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | namespace common\components\OAuth2\Grants; | ||||||
|  |  | ||||||
|  | use common\components\OAuth2\Repositories\ExtendedDeviceCodeRepositoryInterface; | ||||||
|  | use common\components\OAuth2\ResponseTypes\EmptyResponse; | ||||||
|  | use DateInterval; | ||||||
|  | use League\OAuth2\Server\Exception\OAuthServerException; | ||||||
|  | use League\OAuth2\Server\Grant\DeviceCodeGrant as BaseDeviceCodeGrant; | ||||||
|  | use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; | ||||||
|  | use League\OAuth2\Server\RequestTypes\AuthorizationRequest; | ||||||
|  | use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface; | ||||||
|  | use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; | ||||||
|  | use Psr\Http\Message\ServerRequestInterface; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * @property ExtendedDeviceCodeRepositoryInterface $deviceCodeRepository | ||||||
|  |  */ | ||||||
|  | final class DeviceCodeGrant extends BaseDeviceCodeGrant { | ||||||
|  |  | ||||||
|  |     public function __construct( | ||||||
|  |         ExtendedDeviceCodeRepositoryInterface $deviceCodeRepository, | ||||||
|  |         RefreshTokenRepositoryInterface $refreshTokenRepository, | ||||||
|  |         DateInterval $deviceCodeTTL, | ||||||
|  |         string $verificationUri, | ||||||
|  |         int $retryInterval = 5, | ||||||
|  |     ) { | ||||||
|  |         parent::__construct( | ||||||
|  |             $deviceCodeRepository, | ||||||
|  |             $refreshTokenRepository, | ||||||
|  |             $deviceCodeTTL, | ||||||
|  |             $verificationUri, | ||||||
|  |             $retryInterval, | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function canRespondToAuthorizationRequest(ServerRequestInterface $request): bool { | ||||||
|  |         return isset($request->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(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -16,8 +16,7 @@ final class ClientRepository implements ClientRepositoryInterface { | |||||||
|             return null; |             return null; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // @phpstan-ignore argument.type |         return ClientEntity::fromModel($client); | ||||||
|         return new ClientEntity($client->id, $client->name, $client->redirect_uri ?: '', (bool)$client->is_trusted); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool { |     public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool { | ||||||
| @@ -30,7 +29,7 @@ final class ClientRepository implements ClientRepositoryInterface { | |||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if ($clientSecret !== null && $clientSecret !== $client->secret) { |         if (!empty($clientSecret) && $clientSecret !== $client->secret) { | ||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,73 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | namespace common\components\OAuth2\Repositories; | ||||||
|  |  | ||||||
|  | use common\components\OAuth2\Entities\DeviceCodeEntity; | ||||||
|  | use common\models\OauthDeviceCode; | ||||||
|  | use League\OAuth2\Server\Entities\DeviceCodeEntityInterface; | ||||||
|  | use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; | ||||||
|  | use Webmozart\Assert\Assert; | ||||||
|  | use yii\db\Exception; | ||||||
|  |  | ||||||
|  | final class DeviceCodeRepository implements ExtendedDeviceCodeRepositoryInterface { | ||||||
|  |  | ||||||
|  |     public function getNewDeviceCode(): DeviceCodeEntityInterface { | ||||||
|  |         return new DeviceCodeEntity(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function persistDeviceCode(DeviceCodeEntityInterface $deviceCodeEntity): void { | ||||||
|  |         $model = $this->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]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -0,0 +1,16 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | namespace common\components\OAuth2\Repositories; | ||||||
|  |  | ||||||
|  | use League\OAuth2\Server\Entities\DeviceCodeEntityInterface; | ||||||
|  | use League\OAuth2\Server\Repositories\DeviceCodeRepositoryInterface; | ||||||
|  |  | ||||||
|  | interface ExtendedDeviceCodeRepositoryInterface extends DeviceCodeRepositoryInterface { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @phpstan-param non-empty-string $userCode | ||||||
|  |      */ | ||||||
|  |     public function getDeviceCodeEntityByUserCode(string $userCode): ?DeviceCodeEntityInterface; | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								common/components/OAuth2/ResponseTypes/EmptyResponse.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								common/components/OAuth2/ResponseTypes/EmptyResponse.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | namespace common\components\OAuth2\ResponseTypes; | ||||||
|  |  | ||||||
|  | use League\OAuth2\Server\ResponseTypes\AbstractResponseType; | ||||||
|  | use Psr\Http\Message\ResponseInterface; | ||||||
|  |  | ||||||
|  | final class EmptyResponse extends AbstractResponseType { | ||||||
|  |  | ||||||
|  |     public function generateHttpResponse(ResponseInterface $response): ResponseInterface { | ||||||
|  |         return $response; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										47
									
								
								common/models/OauthDeviceCode.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								common/models/OauthDeviceCode.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | namespace common\models; | ||||||
|  |  | ||||||
|  | use yii\behaviors\AttributeTypecastBehavior; | ||||||
|  | use yii\db\ActiveRecord; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Fields: | ||||||
|  |  * @property string $device_code | ||||||
|  |  * @property string $user_code | ||||||
|  |  * @property string $client_id | ||||||
|  |  * @property array $scopes | ||||||
|  |  * @property int|null $account_id | ||||||
|  |  * @property bool|null $is_approved | ||||||
|  |  * @property int|null $last_polled_at | ||||||
|  |  * @property int $expires_at | ||||||
|  |  * | ||||||
|  |  * Relations: | ||||||
|  |  * @property-read OauthClient $client | ||||||
|  |  */ | ||||||
|  | final class OauthDeviceCode extends ActiveRecord { | ||||||
|  |  | ||||||
|  |     public static function tableName(): string { | ||||||
|  |         return 'oauth_device_codes'; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function behaviors(): array { | ||||||
|  |         return [ | ||||||
|  |             [ | ||||||
|  |                 'class' => 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']); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -1,14 +0,0 @@ | |||||||
| <?php |  | ||||||
| namespace common\models\amqp; |  | ||||||
|  |  | ||||||
| use yii\base\BaseObject; |  | ||||||
|  |  | ||||||
| class AccountBanned extends BaseObject { |  | ||||||
|  |  | ||||||
|     public $accountId; |  | ||||||
|  |  | ||||||
|     public $duration = -1; |  | ||||||
|  |  | ||||||
|     public $message = ''; |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @@ -1,10 +0,0 @@ | |||||||
| <?php |  | ||||||
| namespace common\models\amqp; |  | ||||||
|  |  | ||||||
| use yii\base\BaseObject; |  | ||||||
|  |  | ||||||
| class AccountPardoned extends BaseObject { |  | ||||||
|  |  | ||||||
|     public $accountId; |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @@ -1,14 +0,0 @@ | |||||||
| <?php |  | ||||||
| namespace common\models\amqp; |  | ||||||
|  |  | ||||||
| use yii\base\BaseObject; |  | ||||||
|  |  | ||||||
| class EmailChanged extends BaseObject { |  | ||||||
|  |  | ||||||
|     public $accountId; |  | ||||||
|  |  | ||||||
|     public $oldEmail; |  | ||||||
|  |  | ||||||
|     public $newEmail; |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @@ -1,14 +0,0 @@ | |||||||
| <?php |  | ||||||
| namespace common\models\amqp; |  | ||||||
|  |  | ||||||
| use yii\base\BaseObject; |  | ||||||
|  |  | ||||||
| class UsernameChanged extends BaseObject { |  | ||||||
|  |  | ||||||
|     public $accountId; |  | ||||||
|  |  | ||||||
|     public $oldUsername; |  | ||||||
|  |  | ||||||
|     public $newUsername; |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @@ -51,6 +51,7 @@ class FixtureHelper extends Module { | |||||||
|             'usernamesHistory' => fixtures\UsernameHistoryFixture::class, |             'usernamesHistory' => fixtures\UsernameHistoryFixture::class, | ||||||
|             'oauthClients' => fixtures\OauthClientFixture::class, |             'oauthClients' => fixtures\OauthClientFixture::class, | ||||||
|             'oauthSessions' => fixtures\OauthSessionFixture::class, |             'oauthSessions' => fixtures\OauthSessionFixture::class, | ||||||
|  |             'oauthDeviceCodes' => fixtures\OauthDeviceCodeFixture::class, | ||||||
|             'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class, |             'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class, | ||||||
|             'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class, |             'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class, | ||||||
|             'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class, |             'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class, | ||||||
|   | |||||||
							
								
								
									
										20
									
								
								common/tests/fixtures/OauthDeviceCodeFixture.php
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								common/tests/fixtures/OauthDeviceCodeFixture.php
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | namespace common\tests\fixtures; | ||||||
|  |  | ||||||
|  | use common\models\OauthDeviceCode; | ||||||
|  | use yii\test\ActiveFixture; | ||||||
|  |  | ||||||
|  | final class OauthDeviceCodeFixture extends ActiveFixture { | ||||||
|  |  | ||||||
|  |     public $modelClass = OauthDeviceCode::class; | ||||||
|  |  | ||||||
|  |     public $dataFile = '@root/common/tests/fixtures/data/oauth-device-codes.php'; | ||||||
|  |  | ||||||
|  |     public $depends = [ | ||||||
|  |         OauthClientFixture::class, | ||||||
|  |         AccountFixture::class, | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								common/tests/fixtures/data/oauth-device-codes.php
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								common/tests/fixtures/data/oauth-device-codes.php
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | return [ | ||||||
|  |     [ | ||||||
|  |         'device_code' => '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, | ||||||
|  |     ], | ||||||
|  | ]; | ||||||
| @@ -37,7 +37,7 @@ | |||||||
|         "erickskrauch/phpstan-yii2": "dev-master", |         "erickskrauch/phpstan-yii2": "dev-master", | ||||||
|         "guzzlehttp/guzzle": "^6|^7", |         "guzzlehttp/guzzle": "^6|^7", | ||||||
|         "lcobucci/jwt": "^5.4", |         "lcobucci/jwt": "^5.4", | ||||||
|         "league/oauth2-server": "^9.1.0", |         "league/oauth2-server": "dev-master#03dcdd7 as 9.2.0", | ||||||
|         "nesbot/carbon": "^3", |         "nesbot/carbon": "^3", | ||||||
|         "nohnaimer/yii2-sentry": "^2.0", |         "nohnaimer/yii2-sentry": "^2.0", | ||||||
|         "paragonie/constant_time_encoding": "^3", |         "paragonie/constant_time_encoding": "^3", | ||||||
|   | |||||||
							
								
								
									
										25
									
								
								composer.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										25
									
								
								composer.lock
									
									
									
										generated
									
									
									
								
							| @@ -4,7 +4,7 @@ | |||||||
|         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", |         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | ||||||
|         "This file is @generated automatically" |         "This file is @generated automatically" | ||||||
|     ], |     ], | ||||||
|     "content-hash": "1b49f881a8b10f52645cc0b04cf58bf3", |     "content-hash": "b50434a13836bd5adf5d0083e8be7d73", | ||||||
|     "packages": [ |     "packages": [ | ||||||
|         { |         { | ||||||
|             "name": "bacon/bacon-qr-code", |             "name": "bacon/bacon-qr-code", | ||||||
| @@ -1450,16 +1450,16 @@ | |||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|             "name": "league/oauth2-server", |             "name": "league/oauth2-server", | ||||||
|             "version": "9.1.0", |             "version": "dev-master", | ||||||
|             "source": { |             "source": { | ||||||
|                 "type": "git", |                 "type": "git", | ||||||
|                 "url": "https://github.com/thephpleague/oauth2-server.git", |                 "url": "https://github.com/thephpleague/oauth2-server.git", | ||||||
|                 "reference": "d511107cb018ead0bd84f86402b086306738c686" |                 "reference": "03dcdd7" | ||||||
|             }, |             }, | ||||||
|             "dist": { |             "dist": { | ||||||
|                 "type": "zip", |                 "type": "zip", | ||||||
|                 "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/d511107cb018ead0bd84f86402b086306738c686", |                 "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/03dcdd7", | ||||||
|                 "reference": "d511107cb018ead0bd84f86402b086306738c686", |                 "reference": "03dcdd7", | ||||||
|                 "shasum": "" |                 "shasum": "" | ||||||
|             }, |             }, | ||||||
|             "require": { |             "require": { | ||||||
| @@ -1491,6 +1491,7 @@ | |||||||
|                 "slevomat/coding-standard": "^8.14.1", |                 "slevomat/coding-standard": "^8.14.1", | ||||||
|                 "squizlabs/php_codesniffer": "^3.8" |                 "squizlabs/php_codesniffer": "^3.8" | ||||||
|             }, |             }, | ||||||
|  |             "default-branch": true, | ||||||
|             "type": "library", |             "type": "library", | ||||||
|             "autoload": { |             "autoload": { | ||||||
|                 "psr-4": { |                 "psr-4": { | ||||||
| @@ -1534,7 +1535,7 @@ | |||||||
|             ], |             ], | ||||||
|             "support": { |             "support": { | ||||||
|                 "issues": "https://github.com/thephpleague/oauth2-server/issues", |                 "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": [ |             "funding": [ | ||||||
|                 { |                 { | ||||||
| @@ -1542,7 +1543,7 @@ | |||||||
|                     "type": "github" |                     "type": "github" | ||||||
|                 } |                 } | ||||||
|             ], |             ], | ||||||
|             "time": "2024-11-21T22:47:09+00:00" |             "time": "2024-11-25T19:29:16+00:00" | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|             "name": "league/uri", |             "name": "league/uri", | ||||||
| @@ -10862,11 +10863,19 @@ | |||||||
|             "time": "2024-03-03T12:36:25+00:00" |             "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", |     "minimum-stability": "stable", | ||||||
|     "stability-flags": { |     "stability-flags": { | ||||||
|         "ely/yii2-tempmail-validator": 20, |         "ely/yii2-tempmail-validator": 20, | ||||||
|         "erickskrauch/phpstan-yii2": 20, |         "erickskrauch/phpstan-yii2": 20, | ||||||
|  |         "league/oauth2-server": 20, | ||||||
|         "roave/security-advisories": 20 |         "roave/security-advisories": 20 | ||||||
|     }, |     }, | ||||||
|     "prefer-stable": false, |     "prefer-stable": false, | ||||||
|   | |||||||
| @@ -1,6 +1,9 @@ | |||||||
| <?php | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
| namespace console\db; | namespace console\db; | ||||||
|  |  | ||||||
|  | use yii\db\Exception; | ||||||
| use yii\db\Migration as YiiMigration; | use yii\db\Migration as YiiMigration; | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -18,6 +21,9 @@ class Migration extends YiiMigration { | |||||||
|         return $tableOptions; |         return $tableOptions; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @param array<string|\yii\db\ColumnSchemaBuilder>|null $columns | ||||||
|  |      */ | ||||||
|     public function createTable($table, $columns, $options = null): void { |     public function createTable($table, $columns, $options = null): void { | ||||||
|         if ($options === null) { |         if ($options === null) { | ||||||
|             $options = $this->getTableOptions(); |             $options = $this->getTableOptions(); | ||||||
| @@ -34,4 +40,21 @@ class Migration extends YiiMigration { | |||||||
|         return ' PRIMARY KEY (' . implode(', ', $columns) . ') '; |         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'); | ||||||
|  |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										36
									
								
								console/migrations/m241206_172929_oauth_device_codes.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								console/migrations/m241206_172929_oauth_device_codes.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | use console\db\Migration; | ||||||
|  |  | ||||||
|  | class m241206_172929_oauth_device_codes extends Migration { | ||||||
|  |  | ||||||
|  |     public function safeUp(): void { | ||||||
|  |         $this->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'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -5,16 +5,17 @@ | |||||||
|  |  | ||||||
| echo "<?php\n"; | echo "<?php\n"; | ||||||
| ?> | ?> | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
| use console\db\Migration; | use console\db\Migration; | ||||||
|  |  | ||||||
| class <?= $className; ?> extends Migration { | final class <?= $className; ?> extends Migration { | ||||||
|  |  | ||||||
|     public function safeUp() { |     public function safeUp(): void { | ||||||
|  |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function safeDown() { |     public function safeDown(): void { | ||||||
|  |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -810,31 +810,6 @@ parameters: | |||||||
| 			count: 1 | 			count: 1 | ||||||
| 			path: api/tests/functional/_steps/AuthserverSteps.php | 			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\\}\\.$#" | 			message: "#^Offset 1 does not exist on array\\{0\\?\\: string, 1\\?\\: non\\-empty\\-string\\}\\.$#" | ||||||
| 			count: 1 | 			count: 1 | ||||||
| @@ -1430,56 +1405,6 @@ parameters: | |||||||
| 			count: 1 | 			count: 1 | ||||||
| 			path: common/models/UsernameHistory.php | 			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\\.$#" | 			message: "#^Method common\\\\notifications\\\\AccountDeletedNotification\\:\\:getPayloads\\(\\) return type has no value type specified in iterable type array\\.$#" | ||||||
| 			count: 1 | 			count: 1 | ||||||
| @@ -1880,11 +1805,6 @@ parameters: | |||||||
| 			count: 1 | 			count: 1 | ||||||
| 			path: common/validators/UsernameValidator.php | 			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\\(\\)$#" | 			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 | 			count: 1 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user