Move OAuth module from API to common and solve PHPStan's errors

This commit is contained in:
ErickSkrauch
2024-12-06 01:34:09 +01:00
parent 8a25ff9223
commit 5ed6f0ce86
32 changed files with 155 additions and 377 deletions

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2;
use Carbon\CarbonInterval;
use DateInterval;
use League\OAuth2\Server\AuthorizationServer;
use yii\base\Component as BaseComponent;
final class AuthorizationServerFactory extends BaseComponent {
public static function build(): AuthorizationServer {
$clientsRepo = new Repositories\ClientRepository();
$accessTokensRepo = new Repositories\AccessTokenRepository();
$publicScopesRepo = new Repositories\PublicScopeRepository();
$internalScopesRepo = new Repositories\InternalScopeRepository();
$authCodesRepo = new Repositories\AuthCodeRepository();
$refreshTokensRepo = new Repositories\RefreshTokenRepository();
$accessTokenTTL = CarbonInterval::create(-1); // Set negative value to make tokens non expiring
$authServer = new AuthorizationServer(
$clientsRepo,
$accessTokensRepo,
new Repositories\EmptyScopeRepository(),
new Keys\EmptyKey(),
'', // Omit the key because we use our own encryption mechanism
new ResponseTypes\BearerTokenResponse(),
);
/** @noinspection PhpUnhandledExceptionInspection */
$authCodeGrant = new Grants\AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'));
$authCodeGrant->disableRequireCodeChallengeForPublicClients();
$authServer->enableGrantType($authCodeGrant, $accessTokenTTL);
$authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
$refreshTokenGrant = new Grants\RefreshTokenGrant($refreshTokensRepo);
$authServer->enableGrantType($refreshTokenGrant, $accessTokenTTL);
$refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
$clientCredentialsGrant = new Grants\ClientCredentialsGrant();
$authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL);
$clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling
return $authServer;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2;
use LogicException;
use RangeException;
use SodiumException;
use Yii;
/**
* This trait is intended to override the standard data encryption behavior
* with the help of \Defuse\Crypto\Crypto class, because the resultant string
* is much larger than the original one.
*
* The implementation under the hood relies on using libsodium library
* that provides more compact result values.
*/
trait CryptTrait {
protected function encrypt(string $unencryptedData): string {
return Yii::$app->tokens->encryptValue($unencryptedData);
}
protected function decrypt(string $encryptedData): string {
try {
return Yii::$app->tokens->decryptValue($encryptedData);
} catch (SodiumException|RangeException $e) {
throw new LogicException($e->getMessage(), 0, $e);
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Entities;
use League\OAuth2\Server\CryptKeyInterface;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
use Yii;
final class AccessTokenEntity implements AccessTokenEntityInterface {
use EntityTrait;
use TokenEntityTrait;
public function toString(): string {
return Yii::$app->tokensFactory->createForOAuthClient($this)->toString();
}
public function setPrivateKey(CryptKeyInterface $privateKey): void {
// We use a general-purpose component to build JWT tokens, so there is no need to keep the key
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Entities;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
final class AuthCodeEntity implements AuthCodeEntityInterface {
use EntityTrait;
use AuthCodeTrait;
use TokenEntityTrait;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Entities;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\Traits\ClientTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
final class ClientEntity implements ClientEntityInterface {
use EntityTrait;
use ClientTrait;
/**
* @phpstan-param non-empty-string $id
* @phpstan-param string|list<string> $redirectUri
*/
public function __construct(
string $id,
string $name,
string|array $redirectUri,
private readonly bool $isTrusted,
) {
$this->identifier = $id;
$this->name = $name;
$this->redirectUri = $redirectUri;
}
public function isConfidential(): bool {
return true;
}
public function isTrusted(): bool {
return $this->isTrusted;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Entities;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\ScopeTrait;
final class ScopeEntity implements ScopeEntityInterface {
use EntityTrait;
use ScopeTrait;
/**
* @phpstan-param non-empty-string $id
*/
public function __construct(string $id) {
$this->identifier = $id;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Entities;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\UserEntityInterface;
final class UserEntity implements UserEntityInterface {
use EntityTrait;
public function __construct(int $id) {
$this->identifier = (string)$id;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Events;
use League\OAuth2\Server\EventEmitting\AbstractEvent;
final class RequestedRefreshToken extends AbstractEvent {
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Grants;
use common\components\OAuth2\CryptTrait;
use common\components\OAuth2\Events\RequestedRefreshToken;
use common\components\OAuth2\Repositories\PublicScopeRepository;
use DateInterval;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Grant\AuthCodeGrant as BaseAuthCodeGrant;
use League\OAuth2\Server\RequestEvent;
use Psr\Http\Message\ServerRequestInterface;
use yii\helpers\StringHelper;
final class AuthCodeGrant extends BaseAuthCodeGrant {
use CryptTrait;
protected function issueAccessToken(
DateInterval $accessTokenTTL,
ClientEntityInterface $client,
?string $userIdentifier,
array $scopes = [],
): AccessTokenEntityInterface {
foreach ($scopes as $i => $scope) {
if ($scope->getIdentifier() === PublicScopeRepository::OFFLINE_ACCESS) {
unset($scopes[$i]);
$this->getEmitter()->emit(new RequestedRefreshToken('refresh_token_requested'));
}
}
return parent::issueAccessToken($accessTokenTTL, $client, $userIdentifier, $scopes);
}
protected function validateRedirectUri(
string $redirectUri,
ClientEntityInterface $client,
ServerRequestInterface $request,
): void {
$allowedRedirectUris = (array)$client->getRedirectUri();
foreach ($allowedRedirectUris as $allowedRedirectUri) {
if (StringHelper::startsWith($redirectUri, $allowedRedirectUri)) {
return;
}
}
$this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
throw OAuthServerException::invalidClient($request);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Grants;
use common\components\OAuth2\CryptTrait;
use League\OAuth2\Server\Grant\ClientCredentialsGrant as BaseClientCredentialsGrant;
final class ClientCredentialsGrant extends BaseClientCredentialsGrant {
use CryptTrait;
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Grants;
use api\components\Tokens\TokenReader;
use Carbon\FactoryImmutable;
use common\components\OAuth2\CryptTrait;
use common\models\OauthSession;
use InvalidArgumentException;
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
use Lcobucci\JWT\Validation\Validator;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;
use Yii;
final class RefreshTokenGrant extends BaseRefreshTokenGrant {
use CryptTrait;
/**
* Previously, refresh tokens were stored in Redis.
* If received refresh token is matches the legacy token template,
* restore the information from the legacy storage.
*
* @inheritDoc
*/
protected function validateOldRefreshToken(ServerRequestInterface $request, string $clientId): array {
$refreshToken = $this->getRequestParameter('refresh_token', $request);
if ($refreshToken !== null && mb_strlen($refreshToken) === 40) {
return $this->validateLegacyRefreshToken($refreshToken);
}
return $this->validateAccessToken($refreshToken);
}
/**
* Currently we're not rotating refresh tokens.
* So we're overriding this method to always return null, which means,
* that refresh_token will not be issued.
*/
protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface {
return null;
}
/**
* @return array<string, mixed>
* @throws OAuthServerException
*/
private function validateLegacyRefreshToken(string $refreshToken): array {
$result = Yii::$app->redis->get("oauth:refresh:tokens:{$refreshToken}");
if ($result === null) {
throw OAuthServerException::invalidRefreshToken('Token has been revoked');
}
try {
[
'access_token_id' => $accessTokenId,
'session_id' => $sessionId,
] = json_decode((string)$result, true, 512, JSON_THROW_ON_ERROR);
} catch (Throwable $e) {
throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token', $e);
}
/** @var OauthSession|null $relatedSession */
$relatedSession = OauthSession::findOne(['legacy_id' => $sessionId]);
if ($relatedSession === null) {
throw OAuthServerException::invalidRefreshToken('Token has been revoked');
}
return [
'client_id' => $relatedSession->client_id,
'refresh_token_id' => $refreshToken,
'access_token_id' => $accessTokenId,
'scopes' => $relatedSession->getScopes(),
'user_id' => $relatedSession->account_id,
'expire_time' => null,
];
}
/**
* @return array<string, mixed>
* @throws OAuthServerException
*/
private function validateAccessToken(string $jwt): array {
try {
$token = Yii::$app->tokens->parse($jwt);
} catch (InvalidArgumentException $e) {
throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token', $e);
}
if (!Yii::$app->tokens->verify($token)) {
throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token');
}
if (!(new Validator())->validate($token, new LooseValidAt(FactoryImmutable::getDefaultInstance()))) {
throw OAuthServerException::invalidRefreshToken('Token has expired');
}
$reader = new TokenReader($token);
return [
'client_id' => $reader->getClientId(),
'refresh_token_id' => '', // This value used only to invalidate old token
'access_token_id' => '', // This value used only to invalidate old token
'scopes' => $reader->getScopes(),
'user_id' => $reader->getAccountId(),
'expire_time' => null,
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Keys;
use League\OAuth2\Server\CryptKeyInterface;
final class EmptyKey implements CryptKeyInterface {
public function getKeyPath(): string {
return '';
}
public function getPassPhrase(): ?string {
return null;
}
public function getKeyContents(): string {
return '';
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Repositories;
use common\components\OAuth2\Entities\AccessTokenEntity;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
final class AccessTokenRepository implements AccessTokenRepositoryInterface {
/**
* @inheritDoc
* @phpstan-param non-empty-string|null $userIdentifier
*/
public function getNewToken(
ClientEntityInterface $clientEntity,
array $scopes,
?string $userIdentifier = null,
): AccessTokenEntityInterface {
$accessToken = new AccessTokenEntity();
$accessToken->setClient($clientEntity);
array_map($accessToken->addScope(...), $scopes);
if ($userIdentifier !== null) {
$accessToken->setUserIdentifier($userIdentifier);
}
return $accessToken;
}
public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void {
// We don't store access tokens, so there's no need to do anything here
}
public function revokeAccessToken(string $tokenId): void {
// We don't store access tokens, so there's no need to do anything here
}
public function isAccessTokenRevoked(string $tokenId): bool {
return false;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Repositories;
use common\components\OAuth2\Entities\AuthCodeEntity;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
final class AuthCodeRepository implements AuthCodeRepositoryInterface {
public function getNewAuthCode(): AuthCodeEntityInterface {
return new AuthCodeEntity();
}
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void {
}
public function revokeAuthCode(string $codeId): void {
}
public function isAuthCodeRevoked(string $codeId): bool {
return false;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Repositories;
use common\components\OAuth2\Entities\ClientEntity;
use common\models\OauthClient;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
final class ClientRepository implements ClientRepositoryInterface {
public function getClientEntity(string $clientIdentifier): ?ClientEntityInterface {
$client = $this->findModel($clientIdentifier);
if ($client === null) {
return null;
}
// @phpstan-ignore argument.type
return new ClientEntity($client->id, $client->name, $client->redirect_uri ?: '', (bool)$client->is_trusted);
}
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool {
$client = $this->findModel($clientIdentifier);
if ($client === null) {
return false;
}
if ($client->type !== OauthClient::TYPE_APPLICATION) {
return false;
}
if ($clientSecret !== null && $clientSecret !== $client->secret) {
return false;
}
return true;
}
private function findModel(string $id): ?OauthClient {
$client = OauthClient::findOne(['id' => $id]);
if ($client === null || $client->type !== OauthClient::TYPE_APPLICATION) {
return null;
}
return $client;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Repositories;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
/**
* In our application we use separate scopes repositories for different grants.
* To create an instance of the authorization server, you need to pass the scopes
* repository. This class acts as a dummy to meet this requirement.
*/
final class EmptyScopeRepository implements ScopeRepositoryInterface {
public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface {
return null;
}
public function finalizeScopes(
array $scopes,
string $grantType,
ClientEntityInterface $clientEntity,
?string $userIdentifier = null,
?string $authCodeId = null,
): array {
return $scopes;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Repositories;
use api\rbac\Permissions as P;
use common\components\OAuth2\Entities\ClientEntity;
use common\components\OAuth2\Entities\ScopeEntity;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
final class InternalScopeRepository implements ScopeRepositoryInterface {
private const array ALLOWED_SCOPES = [
P::CHANGE_ACCOUNT_USERNAME,
P::CHANGE_ACCOUNT_PASSWORD,
P::BLOCK_ACCOUNT,
P::OBTAIN_EXTENDED_ACCOUNT_INFO,
P::ESCAPE_IDENTITY_VERIFICATION,
];
private const array PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS = [
'internal_account_info' => P::OBTAIN_EXTENDED_ACCOUNT_INFO,
];
public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface {
$identifier = $this->convertToInternalPermission($identifier);
if (!in_array($identifier, self::ALLOWED_SCOPES, true)) {
return null;
}
return new ScopeEntity($identifier);
}
/**
* @throws OAuthServerException
*/
public function finalizeScopes(
array $scopes,
string $grantType,
ClientEntityInterface $clientEntity,
?string $userIdentifier = null,
?string $authCodeId = null,
): array {
if (empty($scopes)) {
return $scopes;
}
/** @var ClientEntity $clientEntity */
// Right now we have no available scopes for the client_credentials grant
if (!$clientEntity->isTrusted()) {
throw OAuthServerException::invalidScope($scopes[0]->getIdentifier());
}
return $scopes;
}
private function convertToInternalPermission(string $publicScope): string {
return self::PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS[$publicScope] ?? $publicScope;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Repositories;
use api\rbac\Permissions as P;
use common\components\OAuth2\Entities\ScopeEntity;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
final class PublicScopeRepository implements ScopeRepositoryInterface {
public const string OFFLINE_ACCESS = 'offline_access';
public const string CHANGE_SKIN = 'change_skin';
private const string ACCOUNT_INFO = 'account_info';
private const string ACCOUNT_EMAIL = 'account_email';
private const array PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS = [
self::ACCOUNT_INFO => P::OBTAIN_OWN_ACCOUNT_INFO,
self::ACCOUNT_EMAIL => P::OBTAIN_ACCOUNT_EMAIL,
];
private const array ALLOWED_SCOPES = [
P::OBTAIN_OWN_ACCOUNT_INFO,
P::OBTAIN_ACCOUNT_EMAIL,
P::MINECRAFT_SERVER_SESSION,
self::OFFLINE_ACCESS,
self::CHANGE_SKIN,
];
public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface {
$identifier = $this->convertToInternalPermission($identifier);
if (!in_array($identifier, self::ALLOWED_SCOPES, true)) {
return null;
}
return new ScopeEntity($identifier);
}
public function finalizeScopes(
array $scopes,
string $grantType,
ClientEntityInterface $clientEntity,
?string $userIdentifier = null,
?string $authCodeId = null,
): array {
return $scopes;
}
private function convertToInternalPermission(string $publicScope): string {
return self::PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS[$publicScope] ?? $publicScope;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\Repositories;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
final class RefreshTokenRepository implements RefreshTokenRepositoryInterface {
public function getNewRefreshToken(): ?RefreshTokenEntityInterface {
return null;
}
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void {
// Do nothing
}
public function revokeRefreshToken(string $tokenId): void {
// Do nothing
}
public function isRefreshTokenRevoked(string $tokenId): bool {
return false;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace common\components\OAuth2\ResponseTypes;
use common\components\OAuth2\CryptTrait;
use League\OAuth2\Server\ResponseTypes\BearerTokenResponse as BaseBearerTokenResponse;
final class BearerTokenResponse extends BaseBearerTokenResponse {
use CryptTrait;
}