mirror of
https://github.com/elyby/accounts.git
synced 2024-12-23 13:50:06 +05:30
Implemented user_code validation
This commit is contained in:
parent
aadbb9a0d9
commit
a2d68677ca
@ -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(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -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,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
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
62
common/components/OAuth2/Grants/DeviceCodeGrant.php
Normal file
62
common/components/OAuth2/Grants/DeviceCodeGrant.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
}
|
@ -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,
|
||||||
|
],
|
||||||
|
];
|
Loading…
Reference in New Issue
Block a user