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:
@@ -6,9 +6,9 @@ namespace common\components\OAuth2;
|
||||
use Carbon\CarbonInterval;
|
||||
use DateInterval;
|
||||
use League\OAuth2\Server\AuthorizationServer;
|
||||
use yii\base\Component as BaseComponent;
|
||||
use Yii;
|
||||
|
||||
final class AuthorizationServerFactory extends BaseComponent {
|
||||
final class AuthorizationServerFactory {
|
||||
|
||||
public static function build(): AuthorizationServer {
|
||||
$clientsRepo = new Repositories\ClientRepository();
|
||||
@@ -17,6 +17,7 @@ final class AuthorizationServerFactory extends BaseComponent {
|
||||
$internalScopesRepo = new Repositories\InternalScopeRepository();
|
||||
$authCodesRepo = new Repositories\AuthCodeRepository();
|
||||
$refreshTokensRepo = new Repositories\RefreshTokenRepository();
|
||||
$deviceCodesRepo = new Repositories\DeviceCodeRepository();
|
||||
|
||||
$accessTokenTTL = CarbonInterval::create(-1); // Set negative value to make tokens non expiring
|
||||
|
||||
@@ -42,6 +43,12 @@ final class AuthorizationServerFactory extends BaseComponent {
|
||||
$authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL);
|
||||
$clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling
|
||||
|
||||
$verificationUri = Yii::$app->request->getHostInfo() . '/code';
|
||||
$deviceCodeGrant = new Grants\DeviceCodeGrant($deviceCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'), $verificationUri);
|
||||
$deviceCodeGrant->setIntervalVisibility(true);
|
||||
$authServer->enableGrantType($deviceCodeGrant, $accessTokenTTL);
|
||||
$deviceCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
|
||||
|
||||
return $authServer;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace common\components\OAuth2\Entities;
|
||||
|
||||
use common\models\OauthClient;
|
||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
||||
use League\OAuth2\Server\Entities\Traits\ClientTrait;
|
||||
use League\OAuth2\Server\Entities\Traits\EntityTrait;
|
||||
@@ -26,6 +27,15 @@ final class ClientEntity implements ClientEntityInterface {
|
||||
$this->redirectUri = $redirectUri;
|
||||
}
|
||||
|
||||
public static function fromModel(OauthClient $model): self {
|
||||
return new self(
|
||||
$model->id, // @phpstan-ignore argument.type
|
||||
$model->name,
|
||||
$model->redirect_uri ?: '',
|
||||
(bool)$model->is_trusted,
|
||||
);
|
||||
}
|
||||
|
||||
public function isConfidential(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// @phpstan-ignore argument.type
|
||||
return new ClientEntity($client->id, $client->name, $client->redirect_uri ?: '', (bool)$client->is_trusted);
|
||||
return ClientEntity::fromModel($client);
|
||||
}
|
||||
|
||||
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool {
|
||||
@@ -30,7 +29,7 @@ final class ClientRepository implements ClientRepositoryInterface {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($clientSecret !== null && $clientSecret !== $client->secret) {
|
||||
if (!empty($clientSecret) && $clientSecret !== $client->secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user