Implemented user_code validation

This commit is contained in:
ErickSkrauch 2024-12-08 15:50:48 +01:00
parent aadbb9a0d9
commit a2d68677ca
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
10 changed files with 198 additions and 32 deletions

View File

@ -270,6 +270,7 @@ final readonly class OauthProcess {
'response_type', 'response_type',
'scope', 'scope',
'state', 'state',
'user_code',
])), ])),
'client' => [ 'client' => [
'id' => $client->id, 'id' => $client->id,
@ -306,14 +307,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(),
]; ];

View File

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

View File

@ -6,7 +6,6 @@ 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 League\OAuth2\Server\Grant\DeviceCodeGrant;
use Yii; use Yii;
final class AuthorizationServerFactory { final class AuthorizationServerFactory {
@ -45,7 +44,7 @@ final class AuthorizationServerFactory {
$clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling
$verificationUri = Yii::$app->request->getHostInfo() . '/code'; $verificationUri = Yii::$app->request->getHostInfo() . '/code';
$deviceCodeGrant = new DeviceCodeGrant($deviceCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'), $verificationUri); $deviceCodeGrant = new Grants\DeviceCodeGrant($deviceCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'), $verificationUri);
$deviceCodeGrant->setIntervalVisibility(true); $deviceCodeGrant->setIntervalVisibility(true);
$authServer->enableGrantType($deviceCodeGrant, $accessTokenTTL); $authServer->enableGrantType($deviceCodeGrant, $accessTokenTTL);
$deviceCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling $deviceCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling

View File

@ -3,6 +3,8 @@ declare(strict_types=1);
namespace common\components\OAuth2\Entities; namespace common\components\OAuth2\Entities;
use Carbon\CarbonImmutable;
use common\models\OauthDeviceCode;
use League\OAuth2\Server\Entities\DeviceCodeEntityInterface; use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
use League\OAuth2\Server\Entities\Traits\DeviceCodeTrait; use League\OAuth2\Server\Entities\Traits\DeviceCodeTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait;
@ -13,4 +15,26 @@ final class DeviceCodeEntity implements DeviceCodeEntityInterface {
use TokenEntityTrait; use TokenEntityTrait;
use DeviceCodeTrait; 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;
}
} }

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Grants;
use common\components\OAuth2\Repositories\ExtendedDeviceCodeRepositoryInterface;
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 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('Client authentication failed', 4, 'invalid_user_code', 401);
}
$authorizationRequest = new AuthorizationRequest();
$authorizationRequest->setGrantTypeId($this->getIdentifier());
$authorizationRequest->setClient($deviceCode->getClient());
$authorizationRequest->setScopes($deviceCode->getScopes());
return $authorizationRequest;
}
}

View File

@ -3,18 +3,14 @@ declare(strict_types=1);
namespace common\components\OAuth2\Repositories; namespace common\components\OAuth2\Repositories;
use Carbon\CarbonImmutable;
use common\components\OAuth2\Entities\ClientEntity;
use common\components\OAuth2\Entities\DeviceCodeEntity; use common\components\OAuth2\Entities\DeviceCodeEntity;
use common\components\OAuth2\Entities\ScopeEntity;
use common\models\OauthDeviceCode; use common\models\OauthDeviceCode;
use League\OAuth2\Server\Entities\DeviceCodeEntityInterface; use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException;
use League\OAuth2\Server\Repositories\DeviceCodeRepositoryInterface;
use Webmozart\Assert\Assert; use Webmozart\Assert\Assert;
use yii\db\Exception; use yii\db\Exception;
final class DeviceCodeRepository implements DeviceCodeRepositoryInterface { final class DeviceCodeRepository implements ExtendedDeviceCodeRepositoryInterface {
public function getNewDeviceCode(): DeviceCodeEntityInterface { public function getNewDeviceCode(): DeviceCodeEntityInterface {
return new DeviceCodeEntity(); return new DeviceCodeEntity();
@ -50,25 +46,16 @@ final class DeviceCodeRepository implements DeviceCodeRepositoryInterface {
return null; return null;
} }
$entity = $this->getNewDeviceCode(); return DeviceCodeEntity::fromModel($model);
$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) { public function getDeviceCodeEntityByUserCode(string $userCode): ?DeviceCodeEntityInterface {
$entity->setUserIdentifier((string)$model->account_id); $model = OauthDeviceCode::findOne(['user_code' => $userCode]);
$entity->setUserApproved((bool)$model->is_approved === true); if ($model === null) {
return null;
} }
if ($model->last_polled_at !== null) { return DeviceCodeEntity::fromModel($model);
$entity->setLastPolledAt(CarbonImmutable::createFromTimestampUTC($model->last_polled_at));
}
return $entity;
} }
public function revokeDeviceCode(string $codeId): void { public function revokeDeviceCode(string $codeId): void {

View File

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

View File

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

View 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,
];
}

View 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,
],
];